Merge branch 'my-uncles-neighbor' into dev

# Conflicts:
#	src/import/RomImporter.lua
This commit is contained in:
bryanthaboi
2026-08-20 08:28:19 -04:00
68 changed files with 18617 additions and 466 deletions
+2 -1
View File
@@ -27,7 +27,8 @@ local ok, err = pcall(function()
CacheFs.prefix = prefix
local manifest = require("src.import.RomManifest").decode(version)
local RomExtractor = version == "gold"
local RomExtractor =
require("src.core.GameVersion").generation(version) == 2
and require("src.import.RomExtractorGen2")
or require("src.import.RomExtractor")
+7 -4
View File
@@ -550,9 +550,10 @@ end
-- 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
-- The block lives in options.lua under `gold` (the historical key; Gold and
-- Silver share it the way the Gen 1 games share the flat namespace), 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"
@@ -699,7 +700,9 @@ end
function LauncherSettings.open(hooks, version)
local opts = SaveData.loadOptions()
local sections
if version == "gold" then
local GameVersion = require("src.core.GameVersion")
if GameVersion.VERSIONS[version]
and GameVersion.generation(version) == 2 then
local block = opts[GEN2_KEY]
if type(block) ~= "table" then
block = {}
+128 -179
View File
@@ -14,9 +14,9 @@
-- height, and flex-shrink compressing text until it overlapped.
--
-- THE RULES THIS FILE FOLLOWS:
-- * Lists paginate (Kit.pager). The installed-mod list also scrolls inside
-- its viewport, so each of its pages can hold at least ten entries without
-- requiring a tall window. Pages still bound how many mod rows we visit.
-- * Short lists paginate (Kit.pager, perPage from Kit.rowsThatFit); the
-- installed-mod list is one continuous scroll instead, drawing only the
-- rows inside the region viewport so the window bounds the frame cost.
-- * Every click handler only QUEUES work (imp._uiActions); update() drains
-- the queue, so an action that tears the view down (Play, Edit save)
-- never runs inside the frame that dispatched it.
@@ -45,9 +45,6 @@ local COMMUNITY_URL = "https://bois.icu"
local ACT_DEDUP = 0.35
-- Finger travel past this (px) is a drag, not a tap.
local TAP_SLOP2 = 16 * 16
-- Installed mods should not turn into a one- or two-item pager on a compact
-- display. Keep a useful page size, then let the list viewport scroll.
local MIN_MODS_PER_PAGE = 10
local MIN_SKIN_ROWS = 4
local SKIN_FORMAT_LABEL = {
native = "GEN1",
@@ -81,15 +78,6 @@ local function setTabScroll(imp, value)
imp._tabScroll[tabKeyOf(imp)] = clamp(value, 0, tabScrollMax(imp))
end
local function modListWantsWheel(imp, wheel)
if imp.tab ~= "mods" or (imp._modScrollMax or 0) <= 0 then return false end
if not inRect(imp._modListRect, Kit.mouseX, Kit.mouseY) then return false end
if not inRect(imp._tabRegionRect, Kit.mouseX, Kit.mouseY) then return false end
local at = clamp(imp.modScroll or 0, 0, imp._modScrollMax)
if wheel < 0 then return at < imp._modScrollMax end
return at > 0
end
-- ------------------------------------------------------------- lifecycle
local function ensureState(imp)
@@ -192,9 +180,6 @@ function LauncherView.touchpressed(imp, id, x, y)
imp._touchAt = imp._touchAt or {}
imp._touchAt[tostring(id)] = {
x = x, y = y,
modsList = imp.tab == "mods" and (imp._modScrollMax or 0) > 0
and inRect(imp._modListRect, x, y)
and inRect(imp._tabRegionRect, x, y),
region = tabScrollMax(imp) > 0 and inRect(imp._tabRegionRect, x, y),
}
end
@@ -207,19 +192,9 @@ function LauncherView.touchmoved(imp, id, x, y)
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
start.dragged = true
end
-- A drag that began in the installed-mod viewport scrolls that page's
-- rows. Its pager remains available for moving to the next ten-plus
-- entries; a drag elsewhere keeps the normal short-window page scroll.
if start.dragged then
local last = start.lastY or start.y
local move = -(y - last)
if start.modsList then
local listMax = imp._modScrollMax or 0
local at, leftover = Kit.scrollHandoff(
clamp(imp.modScroll or 0, 0, listMax), listMax, move)
imp.modScroll = at
move = leftover
end
if move ~= 0 and start.region then
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
tabScrollMax(imp), move)
@@ -343,7 +318,7 @@ end
local CART_COLOR = {
red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold,
gold = PAL.railAmber,
gold = PAL.railAmber, silver = PAL.railSilver,
}
local function cartColor(version)
return CART_COLOR[version] or PAL.green
@@ -942,6 +917,8 @@ local GAME_TABS = {
label = "Yellow" },
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber,
label = "Gold" },
{ id = "silver", key = "tab-silver", letter = "S", color = PAL.railSilver,
label = "Silver" },
}
local HEADER_TABS = {
@@ -1114,16 +1091,11 @@ local function buildHeader(imp, m)
local tabGap = math.floor(6 * m.s)
local tabRowGap = math.floor(4 * m.s)
-- the cartridge dropdown, sized to its longest label so switching games
-- never reflows the row
-- the cartridge dropdown: just the game's initial and the caret; the
-- popup list carries the full names
local chrome0 = headerChrome(imp)
local game = currentGame(imp)
local labelW = 0
for _, g in ipairs(GAME_TABS) do
labelW = math.max(labelW, Kit.textWidth("tab", Strings(g.label)))
end
local dropW = math.min(tabRight - tabLeft,
tabH + labelW + math.floor(34 * m.s))
local dropW = math.min(tabRight - tabLeft, tabH + math.floor(24 * m.s))
chrome0.game.color = game.color
chrome0.game.letter = game.letter
chrome0.game.active = imp.tab == game.id
@@ -1133,7 +1105,7 @@ local function buildHeader(imp, m)
-- flip with it or it vanishes into the cartridge colour
local gameInvert = chrome0.game.active or gameHot
chrome0.game.ring = gameHot and not chrome0.game.active or nil
btn(imp, tx, ty, dropW, tabH, "tab-game", Strings(game.label), chrome0.game)
btn(imp, tx, ty, dropW, tabH, "tab-game", "", chrome0.game)
do
local cw = math.floor(7 * m.s)
local ccx = tx + dropW - math.floor(14 * m.s)
@@ -1964,7 +1936,6 @@ local function buildModsPanel(imp, x, y, w, availH, m)
cy = cy + buildModScopeRow(imp, x, cy, w, m)
if #mods == 0 then
imp.modScroll, imp._modScrollMax, imp._modListRect = 0, 0, nil
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
return (cy - y) + math.floor(110 * m.s)
end
@@ -2005,159 +1976,139 @@ local function buildModsPanel(imp, x, y, w, availH, m)
end
-- A mod row is a fixed height: its details first, then a dedicated second
-- line of per-game checkboxes. Fixed because a page of uniform rows is
-- what lets perPage come from the viewport.
-- line of per-game checkboxes. Fixed row heights are what make the
-- cull below plain arithmetic.
local togH = math.floor(26 * m.s)
local gamesLabel = Strings("Enable for:")
local textH = Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small")
local rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + togH
+ math.floor(8 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local listH = availH - (cy - y) - pagerH - gap
local perPage = Kit.rowsThatFit(listH, rowH, gap, MIN_MODS_PER_PAGE, 20)
local first, last, cur, pages = Kit.pageBounds(page(imp, "mods"), #mods, perPage)
setPage(imp, "mods", cur)
local listTop = cy
local shown = math.max(0, last - first + 1)
local contentH = shown * rowH + math.max(0, shown - 1) * gap
local scrollMax = math.max(0, contentH - listH)
local scroll = clamp(imp.modScroll or 0, 0, scrollMax)
local lr = imp._modListRect
if not lr then lr = {}; imp._modListRect = lr end
lr.x, lr.y, lr.w, lr.h = x, listTop, w, listH
imp._modScrollMax = scrollMax
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks
and Kit.hit(x, listTop, w, listH) then
scroll = clamp(scroll - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
Kit.wheelY = 0
elseif scrollMax == 0 then
local wheelPage = Kit.wheelPage(x, listTop, w, listH, cur, #mods, perPage)
if wheelPage ~= cur then imp.modScroll = 0 end
setPage(imp, "mods", wheelPage)
end
imp.modScroll = scroll
Kit.pushClip(x, listTop, w, listH)
for i = first, last do
-- One continuous list: every row is laid out, the region scroll moves
-- through all of it, and only rows inside the region's viewport draw --
-- so the per-frame cost stays bounded by the window, not the list.
local view = imp._tabRegionRect
local viewTop = view and view.y or listTop
local viewBot = view and (view.y + view.h) or (listTop + availH)
for i = 1, #mods do
local mod = mods[i]
local ry = listTop + (i - first) * (rowH + gap) - scroll
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
local isFullyDisabled = true
if mod.enabledByVersion then
for _, on in pairs(mod.enabledByVersion) do
if on then isFullyDisabled = false; break end
local ry = listTop + (i - 1) * (rowH + gap)
if ry + rowH >= viewTop and ry <= viewBot then
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
local isFullyDisabled = true
if mod.enabledByVersion then
for _, on in pairs(mod.enabledByVersion) do
if on then isFullyDisabled = false; break end
end
else
isFullyDisabled = not mod.enabled
end
else
isFullyDisabled = not mod.enabled
end
local focused = Kit.focusable(rowKey, x, ry, w, rowH)
local hot = focused or Kit.hover(x, ry, w, rowH)
if isFullyDisabled then
Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted")
else
Kit.card(x, ry, w, rowH, hot)
end
local pad = math.floor(12 * m.s)
local px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * m.s)
local togGap = math.floor(5 * m.s) + 1
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
-- These answer separate games, not a single shared install flag. The
-- importer receives the game id so an experimental confirmation also
-- applies only to the checkbox the player pressed.
local flipped = false
local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s)
Kit.text("micro", gamesLabel, px,
gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted)
local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s)
for _, game in ipairs(GameVersion.ORDER) do
local togKey = "mod-toggle-" .. mod.id .. "-" .. game
if modGameCheckbox(tx, gamesY, togH,
mod.enabledByVersion and mod.enabledByVersion[game] == true,
game, togKey, not safeMode) then
local version = game
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
flipped = true
local focused = Kit.focusable(rowKey, x, ry, w, rowH)
local hot = focused or Kit.hover(x, ry, w, rowH)
if isFullyDisabled then
Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted")
else
Kit.card(x, ry, w, rowH, hot)
end
tx = tx + togH + togGap
end
-- The checkboxes sit inside the row's rect, so their press also passes the
-- row hit test; `flipped` gates the row action to everywhere else.
if not flipped
and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then
local id = mod.id
queueAction(imp, rowKey, function() imp._modActions = id end)
end
local textW = inner
local pad = math.floor(12 * m.s)
local px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * 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 - gamesW - math.floor(12 * m.s))
local headingCol = isFullyDisabled and PAL.muted or PAL.heading
Kit.text("button", nameShown, px, ly, headingCol)
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)
local togGap = math.floor(5 * m.s) + 1
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
-- version + status + update state
local statusText, statusCol = modStatusColor(mod.status)
local line = "v" .. tostring(mod.version or "?") .. " " .. statusText
Kit.text("small", line, px, ly, statusCol)
local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s)
if imp:_modInfoPending(mod.id) then
-- An inline spinner, because this row's release check is genuinely in
-- flight -- the list stays usable while it resolves.
Loader.dot(lx, ly, Kit.textHeight("small"))
Kit.text("small", Strings("Checking..."),
lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted)
elseif info and info.status == "available" then
Kit.text("small", Strings("v%s available", tostring(info.latest)),
lx, ly, PAL.yellow)
elseif info and info.status == "current" then
Kit.text("small", Strings("up to date"), lx, ly, PAL.muted)
elseif info and info.status == "error" then
Kit.text("small", Strings("check failed"), lx, ly, PAL.red)
end
ly = ly + Kit.textHeight("small") + math.floor(2 * m.s)
-- one line of description, or the download stats when we have them
-- (download count in green so popularity reads at a glance)
if info and info.downloads then
local d = info.dates
local dl = ModUpdate.downloadsLine(info.downloads.total)
local dates = ModUpdate.datesLine(d and d.first, d and d.latest)
local segs = {}
if dl then segs[#segs + 1] = { dl, PAL.green } end
if dates then
segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail }
-- These answer separate games, not a single shared install flag. The
-- importer receives the game id so an experimental confirmation also
-- applies only to the checkbox the player pressed.
local flipped = false
local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s)
Kit.text("micro", gamesLabel, px,
gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted)
local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s)
for _, game in ipairs(GameVersion.ORDER) do
local togKey = "mod-toggle-" .. mod.id .. "-" .. game
if modGameCheckbox(tx, gamesY, togH,
mod.enabledByVersion and mod.enabledByVersion[game] == true,
game, togKey, not safeMode) then
local version = game
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
flipped = true
end
tx = tx + togH + togGap
end
-- The checkboxes sit inside the row's rect, so their press also passes the
-- row hit test; `flipped` gates the row action to everywhere else.
if not flipped
and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then
local id = mod.id
queueAction(imp, rowKey, function() imp._modActions = id end)
end
local textW = inner
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 - gamesW - math.floor(12 * m.s))
local headingCol = isFullyDisabled and PAL.muted or PAL.heading
Kit.text("button", nameShown, px, ly, headingCol)
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
local statusText, statusCol = modStatusColor(mod.status)
local line = "v" .. tostring(mod.version or "?") .. " " .. statusText
Kit.text("small", line, px, ly, statusCol)
local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s)
if imp:_modInfoPending(mod.id) then
-- An inline spinner, because this row's release check is genuinely in
-- flight -- the list stays usable while it resolves.
Loader.dot(lx, ly, Kit.textHeight("small"))
Kit.text("small", Strings("Checking..."),
lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted)
elseif info and info.status == "available" then
Kit.text("small", Strings("v%s available", tostring(info.latest)),
lx, ly, PAL.yellow)
elseif info and info.status == "current" then
Kit.text("small", Strings("up to date"), lx, ly, PAL.muted)
elseif info and info.status == "error" then
Kit.text("small", Strings("check failed"), lx, ly, PAL.red)
end
ly = ly + Kit.textHeight("small") + math.floor(2 * m.s)
-- one line of description, or the download stats when we have them
-- (download count in green so popularity reads at a glance)
if info and info.downloads then
local d = info.dates
local dl = ModUpdate.downloadsLine(info.downloads.total)
local dates = ModUpdate.datesLine(d and d.first, d and d.latest)
local segs = {}
if dl then segs[#segs + 1] = { dl, PAL.green } end
if dates then
segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail }
end
segLine("small", segs, px, ly, textW)
elseif (mod.description or "") ~= "" then
Kit.text("small", Kit.ellipsize("small", mod.description, textW),
px, ly, PAL.detail)
end
segLine("small", segs, px, ly, textW)
elseif (mod.description or "") ~= "" then
Kit.text("small", Kit.ellipsize("small", mod.description, textW),
px, ly, PAL.detail)
end
end
Kit.popClip()
local pagerY = listTop + listH + gap
local newPage, newPagerH = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods")
if newPage ~= cur then imp.modScroll = 0 end
setPage(imp, "mods", newPage)
return pagerY + newPagerH - y
local contentH = #mods * rowH + (#mods - 1) * gap
return (listTop + contentH + gap) - y
end
-- ---------------------------------------------------------- find mods panel
@@ -4851,8 +4802,7 @@ function LauncherView.draw(imp)
Kit.blockClicks = modalUp(imp)
local step = Kit.scrollStep(m.s)
local nested = modListWantsWheel(imp, Kit.wheelY or 0)
if not nested then
do
local rect = imp._tabRegionRect
if rect then
setTabScroll(imp, (Kit.scrollWheel(tabScrollAt(imp), tabScrollMax(imp),
@@ -4860,8 +4810,7 @@ function LauncherView.draw(imp)
end
end
local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax))
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not nested
and not Kit.blockClicks then
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks then
local moved = math.max(0, math.min(scroll - Kit.wheelY * step, scrollMax))
if moved ~= scroll then
scroll = moved
@@ -4869,7 +4818,7 @@ function LauncherView.draw(imp)
end
end
imp._pageScroll, imp._pageScrollMax = scroll, scrollMax
if (Kit.wheelY or 0) ~= 0 and not nested and not Kit.blockClicks
if (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks
and tabScrollMax(imp) > 0 then
local was = tabScrollAt(imp)
local to = Kit.scrollClamp(was - Kit.wheelY * step, tabScrollMax(imp))
+122 -27
View File
@@ -4,6 +4,7 @@
-- (species order IS dex order, pics/tilesets are lz3-compressed rather than
-- pkmncompress'd, maps are grouped instead of flat). See docs/gold-phase1.md.
local bit = require("bit")
local GameVersion = require("src.core.GameVersion")
local ImageWriter = require("src.import.ImageWriter")
local LuaWriter = require("src.import.LuaWriter")
local Rom = require("src.import.Rom")
@@ -168,6 +169,10 @@ function RomExtractorGen2.new(romData, manifest, progress)
symbols = manifest.symbols,
progress = progress,
stage = 0,
-- _GOLD / _SILVER: the labels are shared, the data behind a handful of
-- them is not (gfx/misc.asm:9-20 vs :46-57).
edition = GameVersion.forSha1(manifest.romSha1) == "silver"
and "silver" or "gold",
}, RomExtractorGen2)
end
@@ -1641,22 +1646,40 @@ function RomExtractorGen2:extractTitle()
return 3
end
-- pret gfx/title/title_bg_gold.pal / title_fg.pal (5 BG pals, 2 OBJ pals).
local BG_PALS = {
local silver = self.edition == "silver"
-- pret gfx/title/title_bg_gold.pal / title_bg_silver.pal (5 BG pals);
-- GSTitleBGPals is the edition-selected include (engine/gfx/color.asm:1234).
local BG_PALS = silver and {
{ { 31, 31, 31 }, { 0, 12, 15 }, { 4, 8, 21 }, { 0, 0, 0 } },
{ { 31, 21, 0 }, { 15, 17, 15 }, { 4, 8, 21 }, { 0, 0, 17 } },
{ { 31, 31, 31 }, { 31, 0, 0 }, { 4, 8, 21 }, { 0, 0, 0 } },
{ { 31, 31, 31 }, { 24, 23, 25 }, { 4, 8, 21 }, { 8, 8, 9 } },
{ { 31, 31, 31 }, { 5, 10, 11 }, { 0, 12, 15 }, { 0, 0, 0 } },
} or {
{ { 31, 31, 31 }, { 18, 23, 31 }, { 15, 20, 31 }, { 0, 0, 0 } },
{ { 31, 21, 0 }, { 12, 14, 12 }, { 15, 20, 31 }, { 0, 0, 17 } },
{ { 31, 31, 31 }, { 31, 0, 0 }, { 15, 20, 31 }, { 0, 0, 0 } },
{ { 31, 31, 31 }, { 29, 25, 0 }, { 15, 20, 31 }, { 17, 10, 1 } },
{ { 31, 31, 31 }, { 23, 26, 31 }, { 18, 23, 31 }, { 0, 0, 0 } },
}
-- title_fg.pal: pal 0 = Ho-Oh silhouette (shades 1-3 are the same brown);
-- pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail).
-- title_fg.pal, shared (GSTitleOBPals, engine/gfx/color.asm:1241): pal 0 =
-- Ho-Oh silhouette; pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail).
local OBJ_HOOH = {
{ 31, 31, 31 }, { 7, 6, 3 }, { 7, 6, 3 }, { 7, 6, 3 },
}
local OBJ_TRAIL = {
{ 31, 31, 31 }, { 31, 31, 0 }, { 26, 22, 0 }, { 0, 0, 0 },
}
-- engine/movie/title.asm:134-141 + CopyPals (home/palettes.asm:190):
-- DmgToCgbObjPal0 %11100000 makes Silver's OBJ pal 0 {c0, c0, c2, c3}.
if silver then
OBJ_HOOH = {
OBJ_HOOH[1], OBJ_HOOH[1], OBJ_HOOH[3], OBJ_HOOH[4],
}
-- .OAMData_GSTitleTrail is attribute 0, not OAM_PAL1 (oam.asm:834-837).
OBJ_TRAIL = OBJ_HOOH
end
local function palColor(pal, shade)
local c = pal[shade + 1] or pal[4]
@@ -1671,8 +1694,9 @@ function RomExtractorGen2:extractTitle()
-- solid BLACK silhouette on a monochrome screen rather than the shaded pose
-- a straight decode gives. rOBP1 (%11111000) carries the gold trail.
local DMG_BGP = { 0, 2, 1, 3 }
local DMG_OBP0 = { 3, 3, 3, 3 }
local DMG_OBP1 = { 0, 2, 3, 3 }
-- engine/movie/title.asm:105-115: Silver writes %11110000 to both OBPs.
local DMG_OBP0 = silver and { 0, 0, 3, 3 } or { 3, 3, 3, 3 }
local DMG_OBP1 = silver and { 0, 0, 3, 3 } or { 0, 2, 3, 3 }
-- ImageWriter's four hardware shades, by shade number.
local DMG_SHADE = { 1, 2 / 3, 1 / 3, 0 }
@@ -1776,6 +1800,28 @@ function RomExtractorGen2:extractTitle()
-- Ho-Oh frames from OAMData_GSIntroHoOh1..5 (data/sprite_anims/oam.asm).
local hoohTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleScreenGFX4"), true)
-- .OAMData_GSIntroLugia1 / 2 (data/sprite_anims/oam.asm:736-773); the
-- spriteanimoam vtile offset is added per frame (core.asm:224-227).
local LUGIA_1 = {
{ -5, -2, 0, 0, 0x00 }, { -5, 0, 0, 0, 0x02 },
{ -4, -2, 0, 0, 0x04 }, { -4, 0, 0, 0, 0x06 },
{ -3, -1, 0, 0, 0x08 }, { -2, -1, 0, 0, 0x0a },
{ -1, -2, 0, 0, 0x0c }, { -1, 0, 0, 0, 0x0e },
{ 0, -2, 0, 0, 0x10 }, { 0, 0, 0, 0, 0x12 },
{ 1, -2, 0, 0, 0x14 }, { 1, 0, 0, 0, 0x16 },
{ 2, -2, 0, 0, 0x18 }, { 2, 0, 0, 0, 0x1a },
{ 3, -1, 0, 0, 0x1c }, { 4, -1, 0, 0, 0x1e },
}
local LUGIA_2 = {
{ -5, -2, 0, 0, 0x00 }, { -5, 0, 0, 0, 0x02 },
{ -4, -2, 0, 0, 0x04 }, { -4, 0, 0, 0, 0x06 },
{ -3, -1, 0, 0, 0x08 }, { -2, -1, 0, 0, 0x0a },
{ -1, -2, 0, 0, 0x0c }, { -1, 0, 0, 0, 0x0e },
{ 0, -2, 0, 0, 0x10 }, { 0, 0, 0, 0, 0x12 },
{ 1, -2, 0, 0, 0x14 }, { 1, 0, 0, 0, 0x16 },
{ 2, -2, 0, 0, 0x18 }, { 2, 0, 0, 0, 0x1a },
{ 3, -2, 0, 0, 0x1c }, { 4, -2, 0, 0, 0x1e },
}
local HOOH_FRAMES = {
{ -- 1
{ -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 },
@@ -1823,22 +1869,36 @@ function RomExtractorGen2:extractTitle()
{ 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 },
},
}
-- Frameset_GSIntroHoOhLugia (Gold): 1,2,3,4,3,5 with these durations.
local HOOH_SEQUENCE = {
-- Silver's five oamsets, as {layout, vtile base} (oam.asm:103-107).
local LUGIA_FRAMES = {
{ LUGIA_1, 0x00 }, { LUGIA_1, 0x20 }, { LUGIA_2, 0x40 },
{ LUGIA_2, 0x60 }, { LUGIA_1, 0x00 },
}
-- Frameset_GSIntroHoOhLugia (data/sprite_anims/framesets.asm:376-396):
-- Gold 1,2,3,4,3,5; Silver 2,1,2,3,3,4,4,3,2 on a faster clock.
local HOOH_SEQUENCE = silver and {
{ 2, 3 }, { 1, 7 }, { 2, 7 }, { 3, 7 }, { 3, 7 },
{ 4, 7 }, { 4, 7 }, { 3, 7 }, { 2, 3 },
} or {
{ 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 },
}
local hoohPaths, hoohGrayPaths = {}, {}
local originX, originY = 32, 24
for fi, oam in ipairs(HOOH_FRAMES) do
-- Lugia1/2 span x tiles -5..4, four tiles wider than Ho-Oh's -4..3.
local originX, originY = silver and 40 or 32, 24
local poseW = silver and 80 or 64
local frames = silver and LUGIA_FRAMES or HOOH_FRAMES
for fi, entry in ipairs(frames) do
local oam = silver and entry[1] or entry
local base = silver and entry[2] or 0
-- The pose starts EMPTY, not white: an OBJ's colour 0 is transparent
-- wherever it falls, so a gap enclosed by the bird shows the sky through
-- exactly like one outside it, and there is no matte to flood-fill.
local pose = ImageWriter.blank(64, 64, 0, 0, 0, 0)
local pose = ImageWriter.blank(poseW, 64, 0, 0, 0, 0)
for _, spr in ipairs(oam) do
local px = originX + spr[1] * 8 + spr[3]
local py = originY + spr[2] * 8 + spr[4]
blitSprite(pose, hoohTiles[spr[5] + 1], px, py)
blitSprite(pose, hoohTiles[spr[5] + 2], px, py + 8)
blitSprite(pose, hoohTiles[base + spr[5] + 1], px, py)
blitSprite(pose, hoohTiles[base + spr[5] + 2], px, py + 8)
end
local tinted = colorize(pose, function() return OBJ_HOOH end)
local rel = ("title/hooh_%d.png"):format(fi)
@@ -1855,14 +1915,20 @@ function RomExtractorGen2:extractTitle()
end
self:tick("Title screen", 3, 5)
-- Trail: TitleScreenGFX3 is raw 2bpp (8 tiles); Gold OAM uses one 8x16
-- on OAM_PAL1 (gold), not the Ho-Oh silhouette pal.
-- Trail: TitleScreenGFX3 is raw 2bpp; Gold's OAM is one 8x16, Silver's two
-- side by side, and only 4 of Silver's 8 copied tiles exist (title.asm:43).
local trailSym = self:symbol("TitleScreenGFX3")
local trailRaw = self.rom:bytes(trailSym.bank, trailSym.address, 8 * 16)
local trailTileCount = silver and 4 or 8
local trailRaw =
self.rom:bytes(trailSym.bank, trailSym.address, trailTileCount * 16)
local trailTiles = tilesFrom2bpp(trailRaw, true)
local trail = ImageWriter.blank(8, 16, 0, 0, 0, 0)
local trail = ImageWriter.blank(silver and 16 or 8, 16, 0, 0, 0, 0)
blitSprite(trail, trailTiles[1], 0, 0)
blitSprite(trail, trailTiles[2], 0, 8)
if silver then
blitSprite(trail, trailTiles[3], 8, 0)
blitSprite(trail, trailTiles[4], 8, 8)
end
local trailTint = colorize(trail, function() return OBJ_TRAIL end)
self:save(trailTint, "title/trail.png")
self:save(throughRegister(trail, DMG_OBP1), "title/trail_gray.png")
@@ -1908,8 +1974,11 @@ function RomExtractorGen2:extractTitle()
cloudsGray = "assets/generated/title/clouds_gray.png",
hoohFramesGray = hoohGrayPaths,
trailGray = "assets/generated/title/trail_gray.png",
-- Frameset_GSIntroHoOhLugia (Gold), frame index 1-based + duration frames.
-- Frameset_GSIntroHoOhLugia, frame index 1-based + duration frames.
hoohSequence = HOOH_SEQUENCE,
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
hoohBobAmplitude = silver and 8 or 2,
hoohBobStep = silver and -1 or 1,
-- `depixel 12, 11` (engine/movie/title.asm). Two traps, and the port had
-- fallen into both, which is what put Ho-Oh off-centre:
-- * ldpixel's own comment calls its first tile argument the X one and is
@@ -1919,19 +1988,45 @@ function RomExtractorGen2:extractTitle()
-- the cursor two rows up on the box screen. So this is x 88, y 96.
-- * those are OAM coordinates, which are biased; a drawn object sits at
-- (x - 8, y - 16) on screen.
-- The pose canvas holds its own origin at (32, 24), so the sheet's corner
-- is (88 - 8 - 32, 96 - 16 - 24) -- and the bird's 64px width then lands
-- centred on the screen, 48 to 112.
hoohX = 48,
hoohY = 56,
-- The pose canvas holds its own origin, so the sheet's corner is
-- (88 - 8 - originX, 96 - 16 - originY) -- and the pose's width then lands
-- centred on the screen (Ho-Oh 48..112, Lugia 40..120).
hoohX = 80 - originX,
hoohY = 80 - originY,
trail = "assets/generated/title/trail.png",
copyright = "assets/generated/title/copyright.png",
copyrightSplash = "assets/generated/title/copyright_splash.png",
-- ScrollTitleScreenClouds: Gold decrements the cloud-band SCX every
-- 8 vblanks, so the strip slides 1px right. Silver does the same
-- decrement every frame.
cloudScrollEvery = 8,
-- ScrollTitleScreenClouds (engine/menus/intro_menu.asm:917-928): Gold
-- decrements the cloud-band SCX every 8 vblanks, so the strip slides 1px
-- right. Silver does the same decrement every frame.
cloudScrollEvery = silver and 1 or 8,
cloudY = 88,
-- BG pal 0 colour 2: the sky the widescreen bands have to match.
sky = {
BG_PALS[1][3][1] / 31, BG_PALS[1][3][2] / 31, BG_PALS[1][3][3] / 31,
},
-- The fill under the cloud/wave band: Gold's cloud field is BG pal 0
-- colour 0 (white), Silver's sea floor is colour 3 (black).
below = silver and {
BG_PALS[1][4][1] / 31, BG_PALS[1][4][2] / 31, BG_PALS[1][4][3] / 31,
} or {
BG_PALS[1][1][1] / 31, BG_PALS[1][1][2] / 31, BG_PALS[1][1][3] / 31,
},
-- UpdateTitleTrailSprite (engine/menus/intro_menu.asm:1069-1124). Silver's
-- `depixel 15, 11, 4, 0` is OAM (88, 124), less the bias and the (-16, -8)
-- corner .OAMData_GSTitleTrail draws from.
trailMode = silver and "silver" or "gold",
trailSpawns = silver and { { 72, 100 } } or {
{ 80, 88 }, { 104, 88 }, { 104, 88 }, { 120, 88 },
{ 120, 88 }, { 88, 88 },
},
trailSpawnEvery = 4,
trailStepX = 4,
trailStepY = silver and 0 or 1,
-- AnimSeq_GSTitleTrail (functions.asm:784-813) with wIntroSceneTimer 0.
trailBobAmplitude = silver and 3 or 2,
trailPhaseStep = silver and 7 or 3,
trailPhase = silver and 0 or nil,
}
self:write("title", data)
return data
+13 -10
View File
@@ -142,6 +142,8 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
"assets/generated/audio/programs.bin",
},
}
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
VERSION_REQUIRED_FILES_OVERRIDE.silver = VERSION_REQUIRED_FILES_OVERRIDE.gold
-- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
-- a dark neon arcade panel, one column per game.
@@ -335,7 +337,7 @@ function RomImporter.syncAndroidShortcuts(activeVersion)
return false
end
local allVersions = { "red", "blue", "yellow", "gold" }
local allVersions = GameVersion.ORDER
local ready = {}
local seen = {}
@@ -1361,11 +1363,10 @@ function RomImporter.new(onComplete, opts)
saveNotice = {},
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
-- (refreshed lazily on first draw and after any toggle/install/delete);
-- modScroll is the current paged list's inner scroll offset (px, clamped
-- in draw); modNotice is the last install/delete result { ok, text }.
-- modNotice is the last install/delete result { ok, text }.
-- requiredImportNotice stays inside the imported-files modal so validation
-- failures are visible beside the file picker that caused them.
mods = nil, modScroll = 0, modNotice = nil, issueNotice = nil,
mods = nil, modNotice = nil, issueNotice = nil,
requiredImportNotice = nil,
-- Which game the MODS panel is answering for (a GameVersion id, nil =
-- every game). Rows resolve their enable-state and their "runs here"
@@ -1422,7 +1423,8 @@ 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" or info.id == "gold") and ".gbc" or ".gb")
.. ((info.id == "yellow" or GameVersion.generation(version) == 2)
and ".gbc" or ".gb")
end
RomImporter.syncAndroidShortcuts()
self:_applyLastVersionTab()
@@ -1687,7 +1689,7 @@ function RomImporter:startData(data, displayName)
end
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.")
.. "2 MiB Game Boy Color ROM (Gold/Silver); this file is %.2f MiB.")
:format(#data / 1024 / 1024))
return
end
@@ -1695,7 +1697,8 @@ 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, Yellow, or Gold dump; patched, trimmed or \"fixed\" dumps "
.. "Red, Blue, Yellow, Gold, or Silver dump; patched, trimmed or "
.. "\"fixed\" dumps "
.. "(tagged [b] or [BF]) never verify."):format(actualHash))
return
end
@@ -1772,7 +1775,7 @@ function RomImporter:_startExtractCoroutine(version, info, prefix, displayName)
local CacheFs = require("src.import.CacheFs")
CacheFs.prefix = prefix
local manifest = require("src.import.RomManifest").decode(version)
local RomExtractor = version == "gold"
local RomExtractor = GameVersion.generation(version) == 2
and require("src.import.RomExtractorGen2")
or require("src.import.RomExtractor")
local extractor = RomExtractor.new(self.romData, manifest,
@@ -2757,7 +2760,8 @@ function RomImporter:resumeAfterOverlay()
end
function RomImporter:_cycleTab(delta)
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins", "bug" }
local order = { "red", "blue", "yellow", "gold", "silver",
"mods", "find", "skins", "bug" }
local idx = 1
for i, id in ipairs(order) do
if id == self.tab then idx = i; break end
@@ -3123,7 +3127,6 @@ function RomImporter:_switchTab(id)
self.tab = id
self._findSearchFocus = false
self._skinUrlFocus = false
self._modScrollMax, self._modListRect = 0, nil
self:_disarmTextInput()
-- the skins list is cheap and can change behind the launcher's back
-- (an export, a hand-dropped folder), so re-read it on every visit