G2 support

This commit is contained in:
bryanthaboi
2026-08-11 11:52:56 -04:00
parent 79ed37699e
commit ae6cac89e1
489 changed files with 226677 additions and 1798 deletions
+133 -7
View File
@@ -329,7 +329,6 @@ local function discoverModSchemas(opts)
local okJson, Json = pcall(require, "src.link.Json")
local okMan, Manifest = pcall(require, "src.mods.Manifest")
if not (okJson and okMan) then return out end
local enabledFlags = opts.mods or {}
local seen = {}
for _, name in ipairs(fs.getDirectoryItems("mods")) do
local path = "mods/" .. name
@@ -341,9 +340,10 @@ local function discoverModSchemas(opts)
if data then okV, m = pcall(Manifest.validate, data, path) end
if okV and m and not seen[m.id] and m.options_schema then
seen[m.id] = true
-- deriveList's enable resolution: a missing entry means enabled,
-- deriveList's enable resolution, through the one reader both mod
-- surfaces use (SaveData.modEnabled): unanswered means enabled,
-- except experimental mods, which stay off until opted in.
local flag = enabledFlags[m.id]
local flag = require("src.core.SaveData").modEnabled(opts, m.id)
local enabled = flag == true or (flag == nil and not m.experimental)
if enabled then
local chunk = fs.load(path .. "/" .. m.options_schema)
@@ -447,17 +447,142 @@ local function modRows(opts, mod)
return rows
end
-- ------- Gen 2 (Gold)
--
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
-- set of names, several of which collide with Gen 1's at a different TYPE
-- (battleStyle "SHIFT" vs "shift", textSpeed a label vs a frame delay), and
-- its renderer has no battle layout, no SGB palette packs and no void fill --
-- so a gear opened on the Gold tab used to offer a dozen controls that did
-- nothing and hide the seven that the cart itself has.
--
-- The block lives in options.lua under `gold`, which is exactly where
-- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the
-- next boot the same way a Gen 1 edit is. Ladders mirror
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
local GEN2_KEY = "gold"
local function gen2Rows(opts)
local rows = {}
local function add(label, value, step)
rows[#rows + 1] = { label = label, value = value, step = step }
end
-- The cart's own seven (engine/menus/options_menu.asm _Option).
add(Strings("TEXT SPEED"), ladder(opts, "textSpeed",
{ { "FAST", "FAST" }, { "MID", "MID" }, { "SLOW", "SLOW" } }, "MID"))
add(Strings("BATTLE SCENE"), ladder(opts, "battleScene",
{ { true, "ON" }, { false, "OFF" } }, true))
add(Strings("BATTLE STYLE"), ladder(opts, "battleStyle",
{ { "SHIFT", "SHIFT" }, { "SET", "SET" } }, "SHIFT"))
add(Strings("SOUND"), ladder(opts, "sound",
{ { "MONO", "MONO" }, { "STEREO", "STEREO" } }, "MONO"))
add(Strings("PRINT"), ladder(opts, "print", {
{ "LIGHTEST", "LIGHTEST" }, { "LIGHTER", "LIGHTER" },
{ "NORMAL", "NORMAL" }, { "DARKER", "DARKER" }, { "DARKEST", "DARKEST" },
}, "NORMAL"))
add(Strings("MENU ACCOUNT"), ladder(opts, "menuAccount",
{ { false, "OFF" }, { true, "ON" } }, true))
-- FRAME is the textbox border, 1-8, wrapping (UpdateFrame masks to 3 bits).
add(Strings("FRAME"),
function() return tostring(opts.frame or 1) end,
function(dir)
opts.frame = wrapIndex((opts.frame or 1) - 1 + (dir or 1), 8) + 1
return true
end)
-- ...then the port's, the same shared modules the Gen 1 rows drive.
add(Strings("MUSIC VOL"),
function() return volLabel(opts.musicVol) end,
function(dir) opts.musicVol = stepVolume(opts.musicVol, dir); return true end)
add(Strings("SFX VOL"),
function() return volLabel(opts.sfxVol) end,
function(dir) opts.sfxVol = stepVolume(opts.sfxVol, dir); return true end)
add(Strings("MUSIC FILTER"),
function() return FILTERS[(opts.musicFilter or 0) + 1] end,
function(dir)
opts.musicFilter = ((opts.musicFilter or 0) + dir) % #FILTERS
return true
end)
local okPal, GbcPalette = pcall(require, "src.render.GbcPalette")
if okPal then
add(Strings("COLOR"),
function() return GbcPalette.modeLabel(opts.color or "gbc") end,
function(dir)
local cur, idx = opts.color or "gbc", 1
for i, mode in ipairs(GbcPalette.MODES) do
if mode == cur then idx = i break end
end
opts.color =
GbcPalette.MODES[wrapIndex(idx - 1 + dir, #GbcPalette.MODES) + 1]
return true
end)
end
local okSpd, GameSpeed = pcall(require, "src.core.GameSpeed")
if okSpd then
add(Strings("GAME SPEED"),
function() return GameSpeed.levelLabel(opts.speed) end,
function(dir)
opts.speed = GameSpeed.cycle(opts.speed, dir)
return true
end)
end
local okTilt, Tilt = pcall(require, "src.render.Tilt")
if okTilt then
add(Strings("TILT"),
function() return Tilt.levelLabel(opts.tilt or 0) end,
function(dir)
opts.tilt = wrapIndex((opts.tilt or 0) + dir, 4)
return true
end)
end
-- Same #136 gate as the Gen 1 row and the in-game one.
local okFx, GBCFX = pcall(require, "src.render.GBCFX")
if okFx and GBCFX.isSupported() then
add(Strings("GBC FX"),
function() return GBCFX.levelLabel(opts.gbcfx or 0) end,
function(dir)
opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5)
return true
end)
end
return rows
end
-- Build the whole settings model: one options table (edited in place),
-- 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.
-- `hooks` carries the host actions a row cannot perform itself:
-- editTouchControls() -- hand the screen to the touch-overlay editor
function LauncherSettings.open(hooks)
--
-- `version` is the game the gear was opened on. It picks the row set, and
-- for Gold it also picks WHICH table the rows edit: the `gold` block inside
-- options.lua rather than the flat Gen 1 one.
function LauncherSettings.open(hooks, version)
local opts = SaveData.loadOptions()
local sections = {
{ title = Strings("OPTIONS"), rows = coreRows(opts, hooks) },
}
local sections
if version == "gold" then
local block = opts[GEN2_KEY]
if type(block) ~= "table" then
block = {}
opts[GEN2_KEY] = block
end
sections = {
{ title = Strings("OPTIONS"), rows = gen2Rows(block) },
}
else
sections = {
{ title = Strings("OPTIONS"), rows = coreRows(opts, hooks) },
}
end
-- Mod options are generation-agnostic (the manager's options_schema
-- contract), so they ride along either way.
for _, mod in ipairs(discoverModSchemas(opts)) do
local rows = modRows(opts, mod)
if #rows > 0 then
@@ -466,6 +591,7 @@ function LauncherSettings.open(hooks)
end
return {
opts = opts,
version = version,
sections = sections,
save = function() SaveData.saveOptions(opts) end,
}
+79 -13
View File
@@ -306,6 +306,7 @@ end
-- the label is read. Unknown versions fall back to the commit green.
local CART_COLOR = {
red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold,
gold = PAL.railAmber,
}
local function cartColor(version)
return CART_COLOR[version] or PAL.green
@@ -314,9 +315,42 @@ 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
-- not a fault: the mod is intact, this is simply not a game it is for
-- (src/mods/ModTargets.lua)
if status == "other_game" then return Strings("Not for this game"), PAL.muted end
return Strings("Incompatible"), PAL.yellow
end
-- MODS panel scope row: which game the list is answering for. Drawn from
-- GameVersion.ORDER so a new game needs nothing here.
local function buildModScopeRow(imp, x, y, w, m)
local GameVersion = require("src.core.GameVersion")
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
local gap = math.floor(6 * m.s)
local label = Strings("Show for:")
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
local options = { { id = nil, label = Strings("All games") } }
for _, version in ipairs(GameVersion.ORDER) do
if imp.ready and imp.ready[version] then
options[#options + 1] =
{ id = version, label = GameVersion.info(version).label }
end
end
if #options < 2 then return 0 end
for _, opt in ipairs(options) do
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong,
"mod-scope-" .. tostring(opt.id or "all")) then
local want = opt.id
queueAction(imp, "mod-scope-" .. tostring(want or "all"),
function() imp:_setModScope(want) end)
end
cx = cx + cw + gap
end
return h + math.floor(8 * m.s)
end
local function findActionFor(entry, installedVersion)
local ModIndex = require("src.mods.ModIndex")
if not ModIndex.canInstall(entry) then
@@ -449,28 +483,48 @@ local function buildHeader(imp, m)
or love.graphics.newImage("assets/launcher/mods.png")
imp._findIcon = imp._findIcon
or love.graphics.newImage("assets/launcher/find.png")
-- The three game tabs keep their cartridge colours -- that is the one piece
-- of brand identity in the launcher, and "the red one" is how people
-- actually refer to these. The colour rides the outline and the glyph at
-- rest and becomes the fill when active, the same rule the buttons follow.
-- Game tabs keep their cartridge colours -- that is the one piece of brand
-- identity in the launcher, and "the red one" is how people actually refer
-- to these. The colour rides the outline and the glyph at rest and becomes
-- the fill when active, the same rule the buttons follow. Yellow stays the
-- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not
-- collide.
local tabs = {
{ id = "red", letter = "R", label = Strings("RED"), color = PAL.railRed },
{ id = "blue", letter = "B", label = Strings("BLUE"), color = PAL.railBlue },
{ id = "yellow", letter = "Y", label = Strings("YELLOW"), color = PAL.railGold },
{ id = "gold", letter = "G", label = Strings("GOLD"), color = PAL.railAmber },
{ id = "mods", icon = imp._modsIcon, label = Strings("MODS") },
{ id = "find", icon = imp._findIcon, label = Strings("FIND MODS") },
}
local tabH = m.chip
local tx = m.x + m.pad
local ty = y + math.floor(6 * m.s)
-- Wrap the strip instead of running off the edge.
--
-- Six tabs used to escape a phone width when an active icon tab spelled its
-- name out (FIND MODS at 412x915). Game tabs (R/B/Y/G) stay glyph-only even
-- when active; only MODS / FIND MODS expand. Still wrap when the next tab
-- would not fit so the divider below moves with the row count.
local tabLeft = tx
local tabRight = m.x + m.w - m.pad
local tabGap = math.floor(6 * m.s)
local tabRowGap = math.floor(4 * m.s)
for _, t in ipairs(tabs) do
local active = imp.tab == t.id
local key = "tab-" .. t.id
local labelW = Kit.textWidth("tab", t.label)
-- The active tab spells its name out; inactive tabs are the glyph alone,
-- so five tabs fit a phone width without wrapping.
local w = active and (tabH + math.floor(8 * m.s) + labelW + math.floor(12 * m.s))
-- Cartridge tabs stay square (letter only). Icon tabs still expand to
-- show MODS / FIND MODS when selected.
local expand = active and t.icon ~= nil
local labelW = expand and Kit.textWidth("tab", t.label) or 0
local w = expand and (tabH + math.floor(8 * m.s) + labelW + math.floor(12 * m.s))
or tabH
-- Never wrap the first tab of a row: if one tab alone is wider than the
-- panel there is nowhere better to put it, and wrapping would loop.
if tx > tabLeft and tx + w > tabRight then
tx = tabLeft
ty = ty + tabH + tabRowGap
end
Kit._audit("control", tx, ty, w, tabH, key)
local focused = Kit.focusable(key, tx, ty, w, tabH)
local hot = focused or Kit.hover(tx, ty, w, tabH)
@@ -497,16 +551,17 @@ local function buildHeader(imp, m)
Kit.textCenter("tab", t.letter, tx,
ty + (tabH - Kit.textHeight("tab")) / 2, tabH, ink)
end
if active then
if expand then
Kit.text("tab", t.label, tx + tabH + math.floor(4 * m.s),
ty + (tabH - Kit.textHeight("tab")) / 2, ink)
end
if Kit.press(tx, ty, w, tabH) or Kit._activateId == key then
queueAction(imp, key, function() imp:_switchTab(t.id) end)
end
tx = tx + w + math.floor(6 * m.s)
tx = tx + w + tabGap
end
-- `ty` has walked down with the wraps, so this stays correct at one row too.
y = ty + tabH + math.floor(8 * m.s)
Theme.fill(m.x, y, m.w, 1, PAL.line, Theme.A.hairline)
return y + math.floor(10 * m.s)
@@ -1127,6 +1182,8 @@ local function buildModsPanel(imp, x, y, w, availH, m)
cy = cy + Kit.textWrapped("small", noticeText, x, cy, w, noticeCol, 2)
+ math.floor(8 * m.s)
cy = cy + buildModScopeRow(imp, x, cy, w, m)
if #mods == 0 then
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
return
@@ -1225,12 +1282,21 @@ local function buildModsPanel(imp, x, y, w, availH, m)
local textW = inner - chipsW - math.floor(12 * m.s)
local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s)
-- the games the mod is for, beside its category: the same chip the
-- in-game manager shows (src/mods/ModTargets.lua)
local gamesW = mod.targets
and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0
local nameShown = Kit.ellipsize("button", mod.name,
textW - badgeW - math.floor(8 * m.s))
textW - badgeW - gamesW - math.floor(12 * m.s))
Kit.text("button", nameShown, px, ly, PAL.heading)
Kit.tag(px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s), ly,
badgeW, Kit.textHeight("button"), mod.badge,
local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s)
Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge,
mod.experimental and PAL.yellow or PAL.muted)
if mod.targets then
Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW,
Kit.textHeight("button"), mod.targets,
mod.targetsHere == false and PAL.steel or PAL.blue)
end
ly = ly + Kit.textHeight("button") + math.floor(4 * m.s)
-- version + status + update state
+99
View File
@@ -208,4 +208,103 @@ function Rom.decompressPic(data)
return output, width
end
-- pokegold's "lz3" compression (home/decompress.asm), used for Gen 2
-- graphics (tilesets, Pokemon pics, title screen art, ...). `data` is a Lua
-- array of bytes (as returned by Rom:bytes) or a raw string; returns a Lua
-- array of decompressed bytes. Ported instruction-for-instruction against
-- pokegold's Decompress routine and cross-checked against tools/lzcompress.c
-- (--uncompress path), which is the canonical reference for this format.
local LZ_END = 0xFF
local LZ_LITERAL = 0
local LZ_ITERATE = 1
local LZ_ALTERNATE = 2
local LZ_ZERO = 3
local LZ_FLIP = 5
local LZ_REVERSE = 6
local LZ_LONG = 7
local function flipBits(value)
local flipped = 0
for bitIndex = 0, 7 do
flipped = flipped + math.floor(value / 2 ^ bitIndex) % 2 * 2 ^ (7 - bitIndex)
end
return flipped
end
function Rom.decompressLz3(data)
local bytes = data
if type(data) == "string" then
bytes = {}
for index = 1, #data do bytes[index] = data:byte(index) end
end
local pos = 1
local function nextByte()
local value = bytes[pos]
if not value then error("lz3 stream ended unexpectedly") end
pos = pos + 1
return value
end
local out = {}
while true do
local first = bytes[pos]
if not first then error("lz3 stream ended without a terminator") end
pos = pos + 1
if first == LZ_END then break end
local command, length
if math.floor(first / 0x20) == LZ_LONG then
-- 111xxxyy yyyyyyyy: xxx is the real command, yy.. is a 10-bit length
command = math.floor(first / 4) % 8
local high = first % 4
length = high * 0x100 + nextByte() + 1
else
command = math.floor(first / 0x20)
length = first % 0x20 + 1
end
if command == LZ_LITERAL then
for _ = 1, length do out[#out + 1] = nextByte() end
elseif command == LZ_ITERATE then
local value = nextByte()
for _ = 1, length do out[#out + 1] = value end
elseif command == LZ_ALTERNATE then
local a, b = nextByte(), nextByte()
for index = 1, length do
out[#out + 1] = (index % 2 == 1) and a or b
end
elseif command == LZ_ZERO then
for _ = 1, length do out[#out + 1] = 0 end
else
-- Lookback commands (LZ_REPEAT/LZ_FLIP/LZ_REVERSE, and the unused id
-- 7 which the hardware routine falls through to LZ_REPEAT for).
-- A high-bit offset byte is a 7-bit negative lookback from the
-- current output position; otherwise it is a 15-bit absolute offset
-- from the start of the output buffer (two bytes, big-endian).
local offsetByte = nextByte()
local from
if offsetByte >= 0x80 then
from = #out - (offsetByte % 0x80)
else
from = offsetByte * 0x100 + nextByte() + 1
end
if command == LZ_FLIP then
for index = 0, length - 1 do
out[#out + 1] = flipBits(out[from + index])
end
elseif command == LZ_REVERSE then
for index = 0, length - 1 do
out[#out + 1] = out[from - index]
end
else
for index = 0, length - 1 do
out[#out + 1] = out[from + index]
end
end
end
end
return out
end
return Rom
File diff suppressed because it is too large Load Diff
+114 -20
View File
@@ -1,5 +1,6 @@
local GameVersion = require("src.core.GameVersion")
local GamepadMap = require("src.core.GamepadMap")
local Logger = require("src.core.Logger")
local Strings = require("src.core.Strings")
local HostShell = require("src.core.HostShell")
local Platform = require("src.core.Platform")
@@ -34,6 +35,12 @@ end
-- their Tilesets row (#889), which a .sav export replays so a Continue on
-- real hardware has a map to load; a v9 cache has none of them and exports
-- the same unbootable save as before.
-- Deliberately NOT bumped for the Gold trainer-pic gap: this tag invalidates
-- every version at once, and that gap is Gold-only. A per-version marker in
-- VERSION_REQUIRED_FILES_OVERRIDE.gold re-imports exactly the caches that lack
-- the stage, which is what the Yellow markers below already do for #439/#557.
-- Reach for a bump when the change spans versions or has no single file to
-- point at.
local CACHE_FORMAT = "rom-cache-v10:"
-- The completion marker is written under each version's cache prefix
-- (red/rom-cache.complete, blue/rom-cache.complete, ...).
@@ -85,6 +92,54 @@ local VERSION_REQUIRED_FILES = {
},
}
-- Gold Phase 1 writes a thinner cache than Gen 1 (no battle anim sheets,
-- trade art, or field.lua payload yet -- see docs/gold-phase1.md). This
-- list replaces REQUIRED_FILES entirely for that version so a successful
-- Gen 2 extract is not stuck as "incomplete" waiting on Gen 1 markers.
local VERSION_REQUIRED_FILES_OVERRIDE = {
gold = {
"data/generated/constants.lua",
"data/generated/maps.lua",
"data/generated/roofs.lua", -- Phase 2: forces re-import of Phase 1 caches
"data/generated/sprites.lua", -- OW sheets (Chris + NPCs)
"data/generated/scripts.lua", -- disassembled map scripts
"data/generated/text.lua", -- decoded Gen 2 dialogue strings
"data/generated/pokemon.lua",
"data/generated/tilesets.lua",
"data/generated/audio.lua",
-- Mart shelves + the heal machine art ride the same import, so listing
-- marts.lua alone re-imports the caches from before either existed
-- (empty shop shelves, no Pokecenter light show).
"data/generated/marts.lua",
"assets/generated/fonts/font.png",
"assets/generated/fonts/frames.png", -- the seven other OPTION textbox frames
"assets/generated/title/pokemon_logo.png",
"assets/generated/title/title_screen.png", -- TitleScreenTilemap composition
"assets/generated/title/hooh.png",
"assets/generated/title/hooh_5.png", -- wing-flap frames force re-import
"assets/generated/title/clouds.png",
"assets/generated/title/copyright_splash.png",
"data/generated/oak_speech.lua", -- Oak texts + trainer pics
"assets/generated/intro/oak.png",
"assets/generated/intro/cal.png",
"assets/generated/tilesets/johto.png",
"assets/generated/tilesets/roofs/new_bark.png",
"assets/generated/sprites/chris.png",
"assets/generated/battle/front/chikorita.png",
"assets/generated/battle/front/pikachu.png",
"assets/generated/battle/front/marill.png", -- Oak speech demo mon
-- The trainer class pics (TrainerPicPointers). FALKNER is row 0 of that
-- table, so a cache that produced any class pic at all produced this one.
-- Listed for the reason the Yellow markers above are: a cache built before
-- the stage existed reads as INCOMPLETE and re-imports itself, so this
-- particular gap cannot survive a tag bump being forgotten again. It
-- costs nothing on a current cache and is the difference between every
-- trainer battle opening with a picture and opening with none.
"assets/generated/battle/trainers/falkner.png",
"assets/generated/audio/programs.bin",
},
}
-- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
-- a dark neon arcade panel, one column per game.
-- Red, Blue, and Yellow share the same importer flow once listed in
@@ -146,11 +201,14 @@ local function allRequiredFilesExist(version)
local saved = CacheFs.prefix
CacheFs.prefix = GameVersion.cachePrefix(version)
local ok = true
for _, path in ipairs(REQUIRED_FILES) do
local required = VERSION_REQUIRED_FILES_OVERRIDE[version] or REQUIRED_FILES
for _, path in ipairs(required) do
if not CacheFs.exists(path) then ok = false; break end
end
for _, path in ipairs(ok and VERSION_REQUIRED_FILES[version] or {}) do
if not CacheFs.exists(path) then ok = false; break end
if ok and not VERSION_REQUIRED_FILES_OVERRIDE[version] then
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
if not CacheFs.exists(path) then ok = false; break end
end
end
CacheFs.prefix = saved
return ok
@@ -350,7 +408,14 @@ local IMPORTS_DIR = "imports"
local BASE_ROMS_DIR = "baseroms"
local MODS_INBOX_DIR = "imports/mods"
local SAVES_INBOX_DIR = "imports/saves"
local ROM_BYTES = 1024 * 1024
local ROM_BYTES_GEN1 = 1024 * 1024
local ROM_BYTES_GEN2 = 2 * 1024 * 1024
-- Historical alias: Gen 1 helpers and tests still refer to ROM_BYTES.
local ROM_BYTES = ROM_BYTES_GEN1
local function isAcceptedRomSize(n)
return n == ROM_BYTES_GEN1 or n == ROM_BYTES_GEN2
end
local function savesInboxDir(version)
return SAVES_INBOX_DIR .. "/" .. tostring(version)
@@ -522,9 +587,9 @@ function RomImporter:_stepBaseRomScan()
scan.index = scan.index + 1
local info = love.filesystem.getInfo(path, "file")
if info and info.size == ROM_BYTES then
if info and isAcceptedRomSize(info.size) then
local data = love.filesystem.read(path)
if type(data) == "string" and #data == ROM_BYTES then
if type(data) == "string" and isAcceptedRomSize(#data) then
local version = GameVersion.forSha1(sha1(data))
if version and not self.ready[version] and not self.baseRoms[version] then
self.baseRoms[version] = {
@@ -766,7 +831,7 @@ function RomImporter:rescanAction(version)
self:setError("The file could not be read: " .. displayName, version)
return
end
if #data ~= ROM_BYTES then
if not isAcceptedRomSize(#data) then
if not junkData then junkData, junkName = data, displayName end
else
local romVersion = GameVersion.forSha1(sha1(data))
@@ -837,12 +902,13 @@ end
-- Only a .gb/.gbc whose SHA maps to a version that is not yet ready counts as
-- pending. GameActivity always writes the SAF pick to picked_rom.gb, so a
-- naive "first ROM wins" scan would re-import Red when the player tries to
-- add Blue (issue #167). Yellow carts are typically .gbc.
-- add Blue (issue #167). Yellow and Gold carts are typically .gbc (Gold is
-- 2 MiB).
local function findPendingRom(ready)
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.gbc?$") and love.filesystem.getInfo(name, "file") then
local data = love.filesystem.read(name)
if type(data) == "string" and #data == 1024 * 1024 then
if type(data) == "string" and isAcceptedRomSize(#data) then
local version = GameVersion.forSha1(sha1(data))
if version and not ready[version] then
return name, data
@@ -866,7 +932,7 @@ local function consumePickedRomError(self)
local preferred = "picked_rom.gb"
if not love.filesystem.getInfo(preferred, "file") then return false end
local data = love.filesystem.read(preferred)
if type(data) == "string" and #data == 1024 * 1024 then
if type(data) == "string" and isAcceptedRomSize(#data) then
local version = GameVersion.forSha1(sha1(data))
if version and self.ready[version] then return false end
end
@@ -1165,6 +1231,10 @@ function RomImporter.new(onComplete, opts)
-- modScroll is the list scroll offset (px, clamped in draw); modNotice is
-- the last install/delete result { ok, text } shown as a line above the list.
mods = nil, modScroll = 0, modNotice = nil,
-- Which game the MODS panel is answering for (a GameVersion id, nil =
-- every game). Rows resolve their enable-state and their "runs here"
-- verdict against it (src/mods/ModTargets.lua).
modScope = nil,
-- FIND MODS panel state (src/mods/ModIndex.lua). findLoaded gates the
-- first fetch the way `mods = nil` gates the mods list, but it is a flag
-- rather than a nil listing because "no index added" is a legitimate
@@ -1215,7 +1285,7 @@ function RomImporter.new(onComplete, opts)
self.returning[version] =
(not ready) and marker ~= nil and marker ~= markerFor(version)
self.romName[version] = "pokemon_" .. info.id
.. (info.id == "yellow" and ".gbc" or ".gb")
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
end
self:_applyLastVersionTab()
self:_queueBaseRomScan()
@@ -1397,6 +1467,16 @@ function RomImporter:setError(message, version)
self.progress = 0
self.worker = nil
self.romData = nil
-- A headless import has no launcher to read this off: POKEPORT_IMPORT_ONLY
-- only ever quits from onComplete, so an import that fails here would sit in
-- the error state forever and look to a build script (or a person) exactly
-- like a hang. Log what broke and exit non-zero instead. Logger, not a
-- literal write: this is a diagnostic for whoever ran the import, never text
-- a player sees, so it is deliberately not a translated string.
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
Logger.error("import failed: %s", tostring(message))
love.event.quit(1)
end
end
-- draw() may leave the system hand cursor set while hovering a Play /
@@ -1424,8 +1504,9 @@ function RomImporter:startData(data, displayName)
self:setError("The selected file could not be read.")
return
end
if #data ~= 1024 * 1024 then
self:setError(("Expected a 1 MiB Game Boy ROM; this file is %.2f MiB.")
if not isAcceptedRomSize(#data) then
self:setError(("Expected a 1 MiB Game Boy ROM (Red/Blue/Yellow) or a "
.. "2 MiB Game Boy Color ROM (Gold); this file is %.2f MiB.")
:format(#data / 1024 / 1024))
return
end
@@ -1433,7 +1514,7 @@ function RomImporter:startData(data, displayName)
local version = GameVersion.forSha1(actualHash)
if not version then
self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon "
.. "Red, Blue, or Yellow dump; patched, trimmed or \"fixed\" dumps "
.. "Red, Blue, Yellow, or Gold dump; patched, trimmed or \"fixed\" dumps "
.. "(tagged [b] or [BF]) never verify."):format(actualHash))
return
end
@@ -1468,7 +1549,9 @@ function RomImporter:startData(data, displayName)
CacheFs.remove(MARKER_PATH)
local manifest = decodeManifest(version)
local RomExtractor = require("src.import.RomExtractor")
local RomExtractor = version == "gold"
and require("src.import.RomExtractorGen2")
or require("src.import.RomExtractor")
local extractor = RomExtractor.new(self.romData, manifest,
function(progress, total, stage, current, stageTotal)
self.status = stage
@@ -2198,7 +2281,7 @@ function RomImporter:resumeAfterOverlay()
end
function RomImporter:_cycleTab(delta)
local order = { "red", "blue", "yellow", "mods", "find" }
local order = { "red", "blue", "yellow", "gold", "mods", "find" }
local idx = 1
for i, id in ipairs(order) do
if id == self.tab then idx = i; break end
@@ -2569,8 +2652,12 @@ function RomImporter:_openSettings()
self.onEditTouchControls()
end
end
-- The tab the gear was opened on decides the row set: Gold reads a
-- different option block entirely, and offering it Gen 1's rows meant a
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
local version = self.tab
local ok, model = pcall(function()
return require("src.import.LauncherSettings").open(hooks)
return require("src.import.LauncherSettings").open(hooks, version)
end)
if ok and model then self._settings = model end
end
@@ -2849,10 +2936,17 @@ function RomImporter:_refreshMods()
.. table.concat(failed, ", ") }
end
end
self.mods = LauncherMods.list() or {}
self.mods = LauncherMods.list(self.modScope) or {}
self:_syncModUpdateInfo(false)
end
-- Point the MODS panel at one game (or nil for all of them) and relist, so
-- every row's status is answered for that game.
function RomImporter:_setModScope(version)
self.modScope = GameVersion.VERSIONS[version] and version or nil
self:_refreshMods()
end
function RomImporter:_ensureMods()
if not self.mods then self:_refreshMods() end
end
@@ -2974,7 +3068,7 @@ function RomImporter:_toggleMod(id, confirmed)
return
end
self._modConfirm = nil
LauncherMods.setEnabled(id, want)
LauncherMods.setEnabled(id, want, self.modScope)
self:_refreshMods()
end
@@ -3014,7 +3108,7 @@ function RomImporter:_setAllMods(want, confirmed)
return
end
self._modConfirm = nil
LauncherMods.setAllEnabled(ids, want)
LauncherMods.setAllEnabled(ids, want, self.modScope)
self:_refreshMods()
self.modNotice = { ok = true, text = want
and Strings("Enabled %d mods.", #ids)