mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 16:21:30 +02:00
G2 support
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user