mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
yellow alpha
This commit is contained in:
+17
-17
@@ -32,11 +32,11 @@ local CacheFs = {}
|
||||
local SEP = package.config:sub(1, 1)
|
||||
|
||||
-- Cache-relative paths are prefixed with this before every read/write, so a
|
||||
-- Blue import lands in blue/ (see src.core.GameVersion) while a Red import
|
||||
-- keeps the historical root. The launcher sets it per import / per readiness
|
||||
-- check; it stays "" for Red. Runtime *reads* (require / newImage) do NOT go
|
||||
-- through here -- CacheFs.mountVersion overlays the active version's subtree
|
||||
-- onto the un-prefixed paths instead.
|
||||
-- Blue/Yellow import lands under its GameVersion.cachePrefix (blue/, yellow/)
|
||||
-- while a Red import keeps the historical root. The launcher sets it per
|
||||
-- import / per readiness check; it stays "" for Red. Runtime *reads*
|
||||
-- (require / newImage) do NOT go through here -- CacheFs.mountVersion overlays
|
||||
-- the active version's subtree onto the un-prefixed paths instead.
|
||||
CacheFs.prefix = ""
|
||||
|
||||
local function withPrefix(rel)
|
||||
@@ -330,16 +330,16 @@ end
|
||||
-- Overlay the active version's extracted cache onto the un-prefixed read
|
||||
-- paths, so require("data.generated.*") and love.graphics.newImage(
|
||||
-- "assets/generated/*") resolve to that version's files. Red lives at the
|
||||
-- cache root and needs nothing; Blue lives under blue/ and is *prepended* so
|
||||
-- it wins over any Red copy at the root and over the game source. Called
|
||||
-- once at boot, before Game:load (main.lua). Returns true when nothing was
|
||||
-- needed or the mount succeeded.
|
||||
-- cache root and needs nothing; non-Red versions (blue/, yellow/, …) are
|
||||
-- *prepended* so they win over any Red copy at the root and over the game
|
||||
-- source. Called once at boot, before Game:load (main.lua). Returns true
|
||||
-- when nothing was needed or the mount succeeded.
|
||||
function CacheFs.mountVersion(version)
|
||||
local prefix = require("src.core.GameVersion").cachePrefix(version)
|
||||
if prefix == "" then return true end -- Red: already at the root
|
||||
local sub = prefix:gsub("/+$", "") -- "blue/" -> "blue"
|
||||
local sub = prefix:gsub("/+$", "") -- "blue/" / "yellow/" -> bare dir
|
||||
-- The cache root is the portable game folder when active, else LÖVE's OS
|
||||
-- save directory (where love.filesystem wrote blue/...).
|
||||
-- save directory (where love.filesystem wrote blue/... or yellow/...).
|
||||
local base = CacheFs.root()
|
||||
if not base and love.filesystem.getSaveDirectory then
|
||||
base = love.filesystem.getSaveDirectory()
|
||||
@@ -355,12 +355,12 @@ function CacheFs.mountVersion(version)
|
||||
end
|
||||
|
||||
-- Undo mountVersion. A process normally mounts exactly one version and then
|
||||
-- boots it, but the launcher can open the save editor on a Blue save, close
|
||||
-- it, and press Play on Red: with blue/ still prepended, Red's
|
||||
-- require("data.generated.*") and its generated art would silently resolve to
|
||||
-- Blue's files. Callers must also drop the generated modules from
|
||||
-- package.loaded (src.core.Data:unloadGenerated) -- unmounting alone only
|
||||
-- fixes the read path, not what require already cached.
|
||||
-- boots it, but the launcher can open the save editor on a Blue/Yellow save,
|
||||
-- close it, and press Play on Red: with that version's subtree still
|
||||
-- prepended, Red's require("data.generated.*") and its generated art would
|
||||
-- silently resolve to the other game's files. Callers must also drop the
|
||||
-- generated modules from package.loaded (src.core.Data:unloadGenerated) --
|
||||
-- unmounting alone only fixes the read path, not what require already cached.
|
||||
--
|
||||
-- Returns true when nothing was mounted or the unmount took. Red is a no-op
|
||||
-- because its cache lives at the root and was never overlaid.
|
||||
|
||||
+345
-49
@@ -163,8 +163,12 @@ function RomExtractor:extractTilesets()
|
||||
for pos = offset, offset + 15 do block[#block + 1] = blocksRaw[pos] end
|
||||
blocks[#blocks + 1] = block
|
||||
end
|
||||
-- Red/Blue keep collision lists in ROM0; Yellow moved them to bank 1
|
||||
-- (pokeyellow Overworld_Coll at 01:4ac2). Pointers in $4000-$7FFF are
|
||||
-- banked; treat ROM0-range pointers as bank 0.
|
||||
local collBank = collisionPointer < 0x4000 and 0 or 1
|
||||
local walkable = sorted(self:readTerminated(
|
||||
0, collisionPointer, 0xFF))
|
||||
collBank, collisionPointer, 0xFF))
|
||||
local warpPointer = self.rom:word(
|
||||
warpPointers.bank, warpPointers.address + (index - 1) * 2)
|
||||
local warpTiles = unique(self:readTerminated(
|
||||
@@ -447,14 +451,26 @@ function RomExtractor:extractSprites()
|
||||
local pointer = self.rom:word(pointerTable.bank, address)
|
||||
local firstHalf = self.rom:byte(pointerTable.bank, address + 2)
|
||||
local bank = self.rom:byte(pointerTable.bank, address + 3)
|
||||
local byteLength = spec.imageWidth * spec.imageHeight / 4
|
||||
local frames = spec.imageHeight / 16
|
||||
local width = spec.imageWidth
|
||||
local height = spec.imageHeight
|
||||
local byteLength = width * height / 4
|
||||
local frames = height / 16
|
||||
local expected = firstHalf * (frames >= 6 and 2 or 1)
|
||||
assert(byteLength == expected, constName .. ": sprite length mismatch")
|
||||
if byteLength ~= expected then
|
||||
-- Commercial ROM sheet length wins over pret PNG atlases (Yellow nurse
|
||||
-- PNG is taller than the 12-tile SpriteSheetPointerTable entry).
|
||||
byteLength = expected
|
||||
assert(byteLength * 4 % width == 0,
|
||||
constName .. ": ROM sprite length not tile-aligned")
|
||||
height = byteLength * 4 / width
|
||||
frames = height / 16
|
||||
expected = firstHalf * (frames >= 6 and 2 or 1)
|
||||
assert(byteLength == expected, constName .. ": sprite length mismatch")
|
||||
end
|
||||
local base = spec.imageBase
|
||||
if not written[base] then
|
||||
self:write2bpp(self.rom:bytes(bank, pointer, byteLength),
|
||||
spec.imageWidth, spec.imageHeight,
|
||||
width, height,
|
||||
"sprites/" .. base .. ".png", true)
|
||||
written[base] = true
|
||||
end
|
||||
@@ -862,20 +878,24 @@ function RomExtractor:extractPalettes()
|
||||
local order = self.manifest.paletteOrder
|
||||
local paletteTable = self:symbol("SuperPalettes")
|
||||
local function scale5(value) return round(value * 255 / 31) end
|
||||
local palettes = {}
|
||||
for index, name in ipairs(order) do
|
||||
local colors = {}
|
||||
for color = 0, 3 do
|
||||
local value = self.rom:word(paletteTable.bank,
|
||||
paletteTable.address + (index - 1) * 8 + color * 2)
|
||||
colors[#colors + 1] = {
|
||||
scale5(bit.band(value, 0x1F)),
|
||||
scale5(bit.band(bit.rshift(value, 5), 0x1F)),
|
||||
scale5(bit.band(bit.rshift(value, 10), 0x1F)),
|
||||
}
|
||||
local function readTable(symbol, names)
|
||||
local out = {}
|
||||
for index, name in ipairs(names) do
|
||||
local colors = {}
|
||||
for color = 0, 3 do
|
||||
local value = self.rom:word(symbol.bank,
|
||||
symbol.address + (index - 1) * 8 + color * 2)
|
||||
colors[#colors + 1] = {
|
||||
scale5(bit.band(value, 0x1F)),
|
||||
scale5(bit.band(bit.rshift(value, 5), 0x1F)),
|
||||
scale5(bit.band(bit.rshift(value, 10), 0x1F)),
|
||||
}
|
||||
end
|
||||
out[name] = colors
|
||||
end
|
||||
palettes[name] = colors
|
||||
return out
|
||||
end
|
||||
local palettes = readTable(paletteTable, order)
|
||||
local monsterTable = self:symbol("MonsterPalettes")
|
||||
local monsterPalettes = {}
|
||||
for index, species in ipairs(self.manifest.dexOrder) do
|
||||
@@ -887,6 +907,11 @@ function RomExtractor:extractPalettes()
|
||||
source = "ROM:SuperPalettes + MonsterPalettes",
|
||||
palettes = palettes, order = order, pokemon = monsterPalettes,
|
||||
}
|
||||
-- Yellow (and GBC carts) also carry CGBBasePalettes beside SuperPalettes.
|
||||
if self.symbols["CGBBasePalettes"] then
|
||||
data.cgbBase = readTable(self:symbol("CGBBasePalettes"), order)
|
||||
data.source = data.source .. " + CGBBasePalettes"
|
||||
end
|
||||
self:write("palettes", data)
|
||||
self:tick("Color palettes", 1, 1)
|
||||
return data
|
||||
@@ -915,6 +940,10 @@ function RomExtractor:extractIcons()
|
||||
GRASS = "assets/generated/icons/plant.png",
|
||||
SNAKE = "assets/generated/icons/snake.png",
|
||||
QUADRUPED = "assets/generated/icons/quadruped.png",
|
||||
-- Yellow's ICON_PIKACHU draws from the overworld PikachuSprite sheet
|
||||
-- (data/icon_pointers.asm mon_icon_header PikachuSprite, 0/12);
|
||||
-- only referenced when the manifest's iconOrder includes it
|
||||
PIKACHU = "assets/generated/sprites/pikachu.png",
|
||||
}
|
||||
local frames = {
|
||||
{ "bug", "BugIconFrame1", "BugIconFrame2" },
|
||||
@@ -1048,7 +1077,9 @@ function RomExtractor:extractPokemon()
|
||||
local typeById = self:typesById()
|
||||
local names = self:symbol("MonsterNames")
|
||||
local baseStats = self:symbol("BaseStats")
|
||||
local mewStats = self:symbol("MewBaseStats")
|
||||
-- Red/Blue keep Mew outside BaseStats (pret pokered MewBaseStats).
|
||||
-- Yellow stores Mew as dex 151 inside BaseStats (pret/pokeyellow).
|
||||
local mewStats = self.symbols["MewBaseStats"] and self:symbol("MewBaseStats")
|
||||
local decodedNames = {}
|
||||
for index = 1, #speciesOrder do
|
||||
decodedNames[index] = self.rom:decodeText(
|
||||
@@ -1067,7 +1098,7 @@ function RomExtractor:extractPokemon()
|
||||
local dex = assert(dexBySpecies[species],
|
||||
"missing dex number for " .. species)
|
||||
local row
|
||||
if species == "MEW" then
|
||||
if species == "MEW" and mewStats then
|
||||
row = self.rom:bytes(mewStats.bank, mewStats.address, 28)
|
||||
else
|
||||
row = self.rom:bytes(
|
||||
@@ -1410,6 +1441,142 @@ function RomExtractor:extractText()
|
||||
}
|
||||
end
|
||||
|
||||
function RomExtractor:extractYellowTitleArt()
|
||||
-- pret/pokeyellow engine/movie/title_yellow.asm: the Yellow title is a
|
||||
-- tilemap composition over BOTH tile banks. LoadYellowTitleScreenGFX
|
||||
-- loads PokemonLogoGraphics into vChars2 (BG ids $00-$7F),
|
||||
-- TitlePikachuBGGraphics into vChars1 (ids $80-$EF),
|
||||
-- TitlePikachuOBGraphics at vChars1 tile $70 (ids $F0-$FC, also the eye
|
||||
-- OAM tiles), and PokemonLogoCornerGraphics at vChars1 tile $7D (ids
|
||||
-- $FD-$FF). Every tilemap mixes ids from several of those sheets, so a
|
||||
-- single-sheet lookup shows checkerboard garbage where a foreign-bank id
|
||||
-- lands (e.g. blank id $00 = logo tile 0, not Pikachu BG tile 0).
|
||||
if not self.symbols["TitlePikachuBGGraphics"] then return end
|
||||
-- raw sheets, kept for debugging / mod reference
|
||||
self:raw2bpp("TitlePikachuBGGraphics", 128, 32,
|
||||
"title/pikachu_bg.png", { transparent = true })
|
||||
self:raw2bpp("TitlePikachuOBGraphics", 96, 8,
|
||||
"title/pikachu_ob.png", { transparent = true })
|
||||
|
||||
-- Tile counts are the Graphics..GraphicsEnd symbol gaps in pokeyellow.sym.
|
||||
local function sheetTiles(label, count, transparent)
|
||||
local symbol = self:symbol(label)
|
||||
local raw = self.rom:bytes(symbol.bank, symbol.address, count * 16)
|
||||
local tiles = {}
|
||||
for offset = 1, #raw, 16 do
|
||||
local one = {}
|
||||
for i = offset, offset + 15 do one[#one + 1] = raw[i] end
|
||||
tiles[#tiles + 1] = ImageWriter.decode2bpp(one, 8, 8, transparent)
|
||||
end
|
||||
return tiles
|
||||
end
|
||||
local logo = sheetTiles("PokemonLogoGraphics", 115)
|
||||
local corner = sheetTiles("PokemonLogoCornerGraphics", 3)
|
||||
local bg = sheetTiles("TitlePikachuBGGraphics", 64)
|
||||
local ob = sheetTiles("TitlePikachuOBGraphics", 12)
|
||||
local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true)
|
||||
local function tileFor(id)
|
||||
if id < 0x80 then return logo[id + 1] end
|
||||
if id < 0xF0 then return bg[id - 0x80 + 1] end
|
||||
if id < 0xFD then return ob[id - 0xF0 + 1] end
|
||||
return corner[id - 0xFD + 1]
|
||||
end
|
||||
-- OAM-style blit: color-0 pixels stay whatever the target already holds
|
||||
-- (ImageWriter.blit copies alpha-0 pixels wholesale, which would punch
|
||||
-- holes into the face under the eye sprites).
|
||||
local function blitSprite(target, tile, tx, ty, flipX)
|
||||
for y = 0, 7 do
|
||||
for x = 0, 7 do
|
||||
local sx = flipX and 7 - x or x
|
||||
local r, g, b, a = tile:getPixel(sx, y)
|
||||
if a ~= 0 then target:setPixel(tx + x, ty + y, r, g, b, a) end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- cells = { {id, col, row}, ... }; untouched cells stay transparent
|
||||
local function compose(cols, rows, cells)
|
||||
local pose = ImageWriter.blank(cols * 8, rows * 8, 1, 1, 1, 0)
|
||||
for _, cell in ipairs(cells) do
|
||||
local tile = tileFor(cell[1])
|
||||
if tile then ImageWriter.blit(pose, tile, cell[2] * 8, cell[3] * 8) end
|
||||
end
|
||||
return pose
|
||||
end
|
||||
local function mapCells(map, cols, rows)
|
||||
local ids = self.rom:bytes(map.bank, map.address, cols * rows)
|
||||
local cells = {}
|
||||
for index, id in ipairs(ids) do
|
||||
cells[#cells + 1] =
|
||||
{ id, (index - 1) % cols, math.floor((index - 1) / cols) }
|
||||
end
|
||||
return cells
|
||||
end
|
||||
|
||||
-- TitleScreen_PlacePokemonLogo: 16x7 box at (2,1). Yellow's logo sheet
|
||||
-- is deduplicated (unlike Red's sequential rip), so the raw2bpp
|
||||
-- pokemon_logo.png from extractField is scrambled; overwrite it with the
|
||||
-- tilemap composition. Kept opaque: TitleState clears to white behind it.
|
||||
self:save(compose(16, 7,
|
||||
mapCells(self:symbol("TitleScreenPokemonLogoTilemap"), 16, 7)),
|
||||
"title/pokemon_logo.png")
|
||||
|
||||
-- TitleScreen_PlacePikaSpeechBubble: 7x4 box at (6,4) plus the two tail
|
||||
-- tiles $64/$65 the routine pokes at (9,8) -- one row below the box, over
|
||||
-- blank cells of the Pikachu row. Composed 7x5 with the tail at (3,4);
|
||||
-- matteColor0 clears the outside-the-balloon whites, the outline protects
|
||||
-- the interior.
|
||||
local bubbleCells = mapCells(
|
||||
self:symbol("TitleScreenPikaBubbleTilemap"), 7, 4)
|
||||
bubbleCells[#bubbleCells + 1] = { 0x64, 3, 4 }
|
||||
bubbleCells[#bubbleCells + 1] = { 0x65, 4, 4 }
|
||||
self:save(ImageWriter.matteColor0(compose(7, 5, bubbleCells)),
|
||||
"title/pika_bubble.png")
|
||||
|
||||
-- TitleScreen_PlacePikachu: 12x9 box at (4,8) plus the right-ear edge
|
||||
-- tiles it pokes down column 16 (rows 10-13) -- composed 13x9 with those
|
||||
-- at relative column 12, rows 2-5. The open eyes are OAM
|
||||
-- (TitleScreenPikachuEyesOAMData, copied at place time): OB tiles 0-3 at
|
||||
-- screen (56,80)/(88,80) blocks, the left eye x-flipped (attr $22); baked
|
||||
-- into the composition relative to the box origin px(32,64).
|
||||
local pikaCells = mapCells(self:symbol("TitleScreenPikachuTilemap"), 12, 9)
|
||||
pikaCells[#pikaCells + 1] = { 0x96, 12, 2 }
|
||||
pikaCells[#pikaCells + 1] = { 0x9d, 12, 3 }
|
||||
pikaCells[#pikaCells + 1] = { 0xa7, 12, 4 }
|
||||
pikaCells[#pikaCells + 1] = { 0xb1, 12, 5 }
|
||||
local pikachu = ImageWriter.matteColor0(compose(13, 9, pikaCells))
|
||||
-- DoTitleScreenFunction's blink rewrites the eye OAM tile ids with
|
||||
-- `and $f3 / or e` (e = 0 open / 4 half / 8 closed), so the OB sheet
|
||||
-- holds three 4-tile eye sets. Bake the open set into pikachu.png and
|
||||
-- save half/closed as standalone overlays for TitleState's blink.
|
||||
local EYE_LAYOUT = {
|
||||
{ 2, 24, 16, true }, { 1, 32, 16, true },
|
||||
{ 4, 24, 24, true }, { 3, 32, 24, true },
|
||||
{ 1, 56, 16 }, { 2, 64, 16 },
|
||||
{ 3, 56, 24 }, { 4, 64, 24 },
|
||||
}
|
||||
-- Blink overlays for the (24,16)-(71,31) eye band: the BG face is
|
||||
-- eyeless (the eyes are OAM), so each overlay = the blank-face crop
|
||||
-- with the half (+4) / closed (+8) tile set composited color-0
|
||||
-- transparent -- exactly what the hardware shows mid-blink.
|
||||
local overlays = {}
|
||||
for suffix, base in pairs({ eyes_half = 4, eyes_closed = 8 }) do
|
||||
local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0)
|
||||
ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16)
|
||||
for _, e in ipairs(EYE_LAYOUT) do
|
||||
blitSprite(overlay, obClear[base + e[1]], e[2] - 24, e[3] - 16, e[4])
|
||||
end
|
||||
overlays[suffix] = overlay
|
||||
end
|
||||
-- open eyes bake into pikachu.png AFTER the blank-face crops
|
||||
for _, e in ipairs(EYE_LAYOUT) do
|
||||
blitSprite(pikachu, obClear[e[1]], e[2], e[3], e[4])
|
||||
end
|
||||
self:save(pikachu, "title/pikachu.png")
|
||||
for suffix, overlay in pairs(overlays) do
|
||||
self:save(overlay, "title/" .. suffix .. ".png")
|
||||
end
|
||||
end
|
||||
|
||||
function RomExtractor:raw2bpp(label, width, height, relative, options)
|
||||
options = options or {}
|
||||
local expected = width * height / 4
|
||||
@@ -1454,6 +1621,8 @@ function RomExtractor:extractField()
|
||||
"title/copyright.png"); tick()
|
||||
self:raw2bpp("GameFreakLogoGraphics", 72, 8,
|
||||
"title/gamefreak_inc.png"); tick()
|
||||
-- Yellow fixed Pikachu title art (no-op on Red/Blue manifests).
|
||||
self:extractYellowTitleArt(); tick()
|
||||
|
||||
local fallingStar = self:raw2bpp(
|
||||
"FallingStar", 8, 8, "intro/falling_star.png",
|
||||
@@ -1502,35 +1671,71 @@ function RomExtractor:extractField()
|
||||
end
|
||||
self:save(star, "intro/big_star.png"); tick()
|
||||
|
||||
local gengar = self:symbol("FightIntroBackMon")
|
||||
local gengarRaw = self.rom:bytes(
|
||||
gengar.bank, gengar.address, 96 * 16)
|
||||
local gengarTiles = {}
|
||||
for offset = 1, #gengarRaw, 16 do
|
||||
local raw = {}
|
||||
for index = offset, offset + 15 do raw[#raw + 1] = gengarRaw[index] end
|
||||
gengarTiles[#gengarTiles + 1] = ImageWriter.decode2bpp(raw, 8, 8)
|
||||
end
|
||||
for number = 1, 3 do
|
||||
local tilemap = self:symbol("GengarIntroTiles" .. number)
|
||||
local tileIds = self.rom:bytes(tilemap.bank, tilemap.address, 49)
|
||||
local pose = ImageWriter.blank(56, 56, 0, 0, 0, 0)
|
||||
for index, tileId in ipairs(tileIds) do
|
||||
ImageWriter.blit(pose, gengarTiles[tileId + 1],
|
||||
(index - 1) % 7 * 8, math.floor((index - 1) / 7) * 8)
|
||||
-- Yellow has no FightIntro Gengar/Nidorino fight (pret/pokeyellow
|
||||
-- engine/movie/intro_yellow.asm); write blank placeholders so Title/
|
||||
-- Intro still find the expected paths. Red/Blue keep the tilemap rip.
|
||||
if self.symbols["FightIntroBackMon"] then
|
||||
local gengar = self:symbol("FightIntroBackMon")
|
||||
local gengarRaw = self.rom:bytes(
|
||||
gengar.bank, gengar.address, 96 * 16)
|
||||
local gengarTiles = {}
|
||||
for offset = 1, #gengarRaw, 16 do
|
||||
local raw = {}
|
||||
for index = offset, offset + 15 do raw[#raw + 1] = gengarRaw[index] end
|
||||
gengarTiles[#gengarTiles + 1] = ImageWriter.decode2bpp(raw, 8, 8)
|
||||
end
|
||||
for number = 1, 3 do
|
||||
local tilemap = self:symbol("GengarIntroTiles" .. number)
|
||||
local tileIds = self.rom:bytes(tilemap.bank, tilemap.address, 49)
|
||||
local pose = ImageWriter.blank(56, 56, 0, 0, 0, 0)
|
||||
for index, tileId in ipairs(tileIds) do
|
||||
ImageWriter.blit(pose, gengarTiles[tileId + 1],
|
||||
(index - 1) % 7 * 8, math.floor((index - 1) / 7) * 8)
|
||||
end
|
||||
pose = ImageWriter.matteColor0(pose)
|
||||
self:save(pose, "intro/gengar_" .. number .. ".png"); tick()
|
||||
end
|
||||
else
|
||||
for number = 1, 3 do
|
||||
self:save(ImageWriter.blank(56, 56, 0, 0, 0, 0),
|
||||
"intro/gengar_" .. number .. ".png"); tick()
|
||||
end
|
||||
pose = ImageWriter.matteColor0(pose)
|
||||
self:save(pose, "intro/gengar_" .. number .. ".png"); tick()
|
||||
end
|
||||
|
||||
for number, label in ipairs({
|
||||
"FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3",
|
||||
}) do
|
||||
self:raw2bpp(label, 48, 48,
|
||||
"intro/red_nidorino_" .. number .. ".png",
|
||||
{ transparent = true, columns = true })
|
||||
tick()
|
||||
if self.symbols["FightIntroFrontMon"] then
|
||||
for number, label in ipairs({
|
||||
"FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3",
|
||||
}) do
|
||||
self:raw2bpp(label, 48, 48,
|
||||
"intro/red_nidorino_" .. number .. ".png",
|
||||
{ transparent = true, columns = true })
|
||||
tick()
|
||||
end
|
||||
else
|
||||
for number = 1, 3 do
|
||||
self:save(ImageWriter.blank(48, 48, 1, 1, 1, 0),
|
||||
"intro/red_nidorino_" .. number .. ".png"); tick()
|
||||
end
|
||||
end
|
||||
|
||||
-- Optional Yellow-only intro atlas (pret/pokeyellow gfx/yellow_intro.asm).
|
||||
if self.symbols["YellowIntroGraphics1"] then
|
||||
self:raw2bpp("YellowIntroGraphics1", 128, 64,
|
||||
"intro/yellow_intro_1.png")
|
||||
end
|
||||
if self.symbols["YellowIntroGraphics2"] then
|
||||
-- atlas2 doubles as the intro's OBJ tile bank (vChars0); OBJ color 0
|
||||
-- is hardware-transparent, and the BG draws it over a white clear so
|
||||
-- BG cells lose nothing
|
||||
self:raw2bpp("YellowIntroGraphics2", 128, 128,
|
||||
"intro/yellow_intro_2.png", { transparent = true })
|
||||
end
|
||||
-- Yellow intro clouds (intro_yellow.asm YellowIntroCloudGFX): 8 tiles,
|
||||
-- two 4-tile animation frames -- saved 32x16, one frame per row.
|
||||
if self.symbols["YellowIntroCloudGFX"] then
|
||||
self:raw2bpp("YellowIntroCloudGFX", 32, 16, "intro/clouds.png")
|
||||
end
|
||||
|
||||
for number = 1, 2 do
|
||||
self:writeCompressedPic(
|
||||
"ShrinkPic" .. number, "intro/shrink" .. number .. ".png")
|
||||
@@ -1563,10 +1768,27 @@ function RomExtractor:extractField()
|
||||
end
|
||||
self:save(symbolSheet, "slots/symbols.png"); tick()
|
||||
|
||||
local emotes = ImageWriter.blank(48, 16, 1, 1, 1, 0)
|
||||
for index, label in ipairs({
|
||||
"ShockEmote", "QuestionEmote", "HappyEmote",
|
||||
}) do
|
||||
-- Emote sheet layout comes from manifest.field.emotionBubbles so the
|
||||
-- versions can differ: Red ships the three shared bubbles, Yellow adds
|
||||
-- the five Pikachu-only ones (emotion_bubbles.asm Skull/Heart/Bolt/
|
||||
-- Zzz/FishEmote, used by the PikachuEmotionTable reactions).
|
||||
local EMOTE_SYMBOLS = {
|
||||
EXCLAMATION_BUBBLE = "ShockEmote", QUESTION_BUBBLE = "QuestionEmote",
|
||||
SMILE_BUBBLE = "HappyEmote", SKULL_BUBBLE = "SkullEmote",
|
||||
HEART_BUBBLE = "HeartEmote", BOLT_BUBBLE = "BoltEmote",
|
||||
ZZZ_BUBBLE = "ZzzEmote", FISH_BUBBLE = "FishEmote",
|
||||
}
|
||||
local bubbleDefs = self.manifest.field.emotionBubbles
|
||||
and self.manifest.field.emotionBubbles.bubbles
|
||||
local emoteLabels = {}
|
||||
for _, b in ipairs(bubbleDefs or {}) do
|
||||
emoteLabels[#emoteLabels + 1] = EMOTE_SYMBOLS[b.name]
|
||||
end
|
||||
if #emoteLabels == 0 then
|
||||
emoteLabels = { "ShockEmote", "QuestionEmote", "HappyEmote" }
|
||||
end
|
||||
local emotes = ImageWriter.blank(#emoteLabels * 16, 16, 1, 1, 1, 0)
|
||||
for index, label in ipairs(emoteLabels) do
|
||||
local symbol = self:symbol(label)
|
||||
local image = ImageWriter.decode2bpp(
|
||||
self.rom:bytes(symbol.bank, symbol.address, 64), 16, 16, true)
|
||||
@@ -1574,6 +1796,26 @@ function RomExtractor:extractField()
|
||||
end
|
||||
self:save(emotes, "emotes.png"); tick()
|
||||
|
||||
-- Yellow-only: the Surfing Pikachu minigame sheets
|
||||
-- (gfx/surfing_pikachu.asm) at pret's canvas widths, so
|
||||
-- src/ui/SurfingMinigame.lua's quads can be read off the source pngs.
|
||||
-- 1a is the BG set (water/beach/score tiles, opaque); 1b the OAM pose
|
||||
-- sheet and 1c the intro set (both color-0 transparent).
|
||||
for _, spec in ipairs({
|
||||
{ "SurfingPikachu1Graphics1", 65, 40, false, "minigame/surf_1a.png" },
|
||||
{ "SurfingPikachu1Graphics2", 256, 128, true, "minigame/surf_1b.png" },
|
||||
{ "SurfingPikachu1Graphics3", 144, 96, true, "minigame/surf_1c.png" },
|
||||
}) do
|
||||
if self.symbols[spec[1]] then
|
||||
local symbol = self:symbol(spec[1])
|
||||
local tilesPerRow = spec[3] / 8
|
||||
local image = ImageWriter.decode2bpp(
|
||||
self.rom:bytes(symbol.bank, symbol.address, spec[2] * 16),
|
||||
spec[3], spec[2] / tilesPerRow * 8, spec[4])
|
||||
self:save(image, spec[5])
|
||||
end
|
||||
end
|
||||
|
||||
self:raw1bpp("LedgeHoppingShadow", 8, 8,
|
||||
"fx/shadow.png", true); tick()
|
||||
for _, spec in ipairs({
|
||||
@@ -1664,7 +1906,9 @@ end
|
||||
function RomExtractor:extractAudio()
|
||||
self:beginStage("Sound programs")
|
||||
local metadata = copy(self.manifest.audio)
|
||||
local bankOrder = { 2, 8, 31 }
|
||||
-- Yellow adds a fourth music bank ($20: Jessie & James, Surfing
|
||||
-- Pikachu, GB Printer); the manifest names the pack when it needs it.
|
||||
local bankOrder = metadata.programBanks or { 2, 8, 31 }
|
||||
local chunks = {}
|
||||
for index, bank in ipairs(bankOrder) do
|
||||
local first = Rom.offset(bank, 0x4000) + 1
|
||||
@@ -1683,6 +1927,7 @@ function RomExtractor:extractAudio()
|
||||
for name, header in pairs(metadata.musicHeaders) do
|
||||
songs[name] = header
|
||||
end
|
||||
metadata.pikaCries = self:extractPikachuCries()
|
||||
local cries = {}
|
||||
local cryData = metadata.cryData
|
||||
for index, species in ipairs(self.manifest.constants.speciesOrder) do
|
||||
@@ -1709,6 +1954,57 @@ function RomExtractor:extractAudio()
|
||||
return metadata
|
||||
end
|
||||
|
||||
-- Yellow's voiced Pikachu clips (audio/pikachu_cries_pointers.asm
|
||||
-- PikachuCriesPointerTable, 42 `dba` rows; each clip is `dw length` then
|
||||
-- 1-bit PCM, MSB first -- home/pikachu_cries.asm PlayPikachuPCM toggles
|
||||
-- rAUD3LEVEL per bit at roughly 190 CPU cycles a sample). Decoded to
|
||||
-- plain 8-bit mono WAVs; returns the clip count for data.audio.pikaCries,
|
||||
-- or nil when the manifest has no pointer table (Red/Blue).
|
||||
function RomExtractor:extractPikachuCries()
|
||||
if not self.symbols["PikachuCriesPointerTable"] then return nil end
|
||||
local NUM = 42 -- NUM_PIKA_CRIES
|
||||
local RATE = 22050 -- ~4.19 MHz / ~190 cycles per sample
|
||||
-- byte -> 8 samples, MSB first (LoadNextSoundClipSample: `and $80`)
|
||||
local lut = {}
|
||||
for byte = 0, 255 do
|
||||
local out = {}
|
||||
for bit = 7, 0, -1 do
|
||||
local on = math.floor(byte / 2 ^ bit) % 2 == 1
|
||||
out[#out + 1] = string.char(on and 0xE0 or 0x20)
|
||||
end
|
||||
lut[byte] = table.concat(out)
|
||||
end
|
||||
local function u16(v)
|
||||
return string.char(v % 256, math.floor(v / 256) % 256)
|
||||
end
|
||||
local function u32(v)
|
||||
return string.char(v % 256, math.floor(v / 256) % 256,
|
||||
math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256)
|
||||
end
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local pointers = self:symbol("PikachuCriesPointerTable")
|
||||
for index = 0, NUM - 1 do
|
||||
local row = self.rom:bytes(pointers.bank, pointers.address + index * 3, 3)
|
||||
local bank, address = row[1], row[2] + row[3] * 256
|
||||
local header = self.rom:bytes(bank, address, 2)
|
||||
local length = header[1] + header[2] * 256
|
||||
local raw = self.rom:bytes(bank, address + 2, length)
|
||||
local samples = {}
|
||||
for i, byte in ipairs(raw) do samples[i] = lut[byte] end
|
||||
local pcm = table.concat(samples)
|
||||
local wav = "RIFF" .. u32(36 + #pcm) .. "WAVEfmt " .. u32(16)
|
||||
.. u16(1) .. u16(1) .. u32(RATE) .. u32(RATE) .. u16(1) .. u16(8)
|
||||
.. "data" .. u32(#pcm) .. pcm
|
||||
local ok, err = CacheFs.write(
|
||||
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(index + 1),
|
||||
wav)
|
||||
if not ok then
|
||||
error("could not write pika cry " .. (index + 1) .. ": " .. tostring(err))
|
||||
end
|
||||
end
|
||||
return NUM
|
||||
end
|
||||
|
||||
function RomExtractor:run()
|
||||
local results = {}
|
||||
results.constants = self:extractConstants()
|
||||
|
||||
+64
-53
@@ -37,8 +37,8 @@ local REQUIRED_FILES = {
|
||||
|
||||
-- "Split-screen ROM selector" first-run palette (matches FirstRun.dc.html from
|
||||
-- the Claude Design project): a dark neon arcade panel, one column per game.
|
||||
-- Red is live; Blue and Yellow are lit placeholders until those games are
|
||||
-- supported. Values are 0-255 RGB; alpha is applied per draw.
|
||||
-- Red, Blue, and Yellow share the same importer flow once listed in
|
||||
-- GameVersion.VERSIONS. Values are 0-255 RGB; alpha is applied per draw.
|
||||
local PAL = {
|
||||
-- radial background gradient (bright navy at top-centre -> near black)
|
||||
bgTop = { 22, 34, 74 }, -- #16224a
|
||||
@@ -302,13 +302,13 @@ end
|
||||
-- it directly through love.filesystem -- already mounted at the physfs
|
||||
-- root, so no io.* absolute-path handling is needed.
|
||||
--
|
||||
-- Only a .gb whose SHA maps to a version that is not yet ready counts as
|
||||
-- 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 .gb wins" scan would re-import Red when the player tries to
|
||||
-- add Blue (issue #167).
|
||||
-- naive "first ROM wins" scan would re-import Red when the player tries to
|
||||
-- add Blue (issue #167). Yellow carts are typically .gbc.
|
||||
local function findPendingRom(ready)
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
if name:lower():match("%.gb$") and love.filesystem.getInfo(name, "file") then
|
||||
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
|
||||
local version = GameVersion.forSha1(sha1(data))
|
||||
@@ -360,14 +360,14 @@ local function chooseRom(promptName)
|
||||
local platform = love.system.getOS()
|
||||
if platform == "OS X" then
|
||||
return commandOutput(
|
||||
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"gb"})' 2>/dev/null]])
|
||||
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"gb", "gbc"})' 2>/dev/null]])
|
||||
:format(prompt))
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='Game Boy ROM (*.gb)|*.gb|All files (*.*)|*.*';",
|
||||
"$d.Filter='Game Boy ROM (*.gb;*.gbc)|*.gb;*.gbc|All files (*.*)|*.*';",
|
||||
-- write the pick as UTF-8: the console's OEM codepage would mangle
|
||||
-- non-ASCII names (Pokémon -> Pok\x82mon) and crash any text draw
|
||||
-- that shows them (#325)
|
||||
@@ -377,11 +377,11 @@ local function chooseRom(promptName)
|
||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||
elseif platform == "Linux" then
|
||||
local path = commandOutput(
|
||||
([[zenity --file-selection --title="%s" --file-filter="Game Boy ROM | *.gb" 2>/dev/null]])
|
||||
([[zenity --file-selection --title="%s" --file-filter="Game Boy ROM | *.gb *.gbc" 2>/dev/null]])
|
||||
:format(prompt))
|
||||
if path then return path end
|
||||
return commandOutput(
|
||||
[[kdialog --getopenfilename "$HOME" "*.gb|Game Boy ROM" 2>/dev/null]])
|
||||
[[kdialog --getopenfilename "$HOME" "*.gb *.gbc|Game Boy ROM" 2>/dev/null]])
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -470,10 +470,11 @@ local function updaterAllowed()
|
||||
return true
|
||||
end
|
||||
|
||||
-- The launcher runs Red and Blue as two independent columns. Each dropped or
|
||||
-- The launcher runs each GameVersion as an independent tab. Each dropped or
|
||||
-- chosen ROM is routed to its version by SHA-1, extracted into that version's
|
||||
-- own cache (Red at the root, Blue under blue/), so both can be imported and
|
||||
-- played side by side. onComplete(version) hands the chosen game off to boot.
|
||||
-- own cache (Red at the root, Blue under blue/, Yellow under yellow/), so all
|
||||
-- can be imported and played side by side. onComplete(version) hands the
|
||||
-- chosen game off to boot.
|
||||
-- opts: launcher (a fresh import stays on the launcher instead of auto-booting),
|
||||
-- forceImport (treat every version as not-yet-imported, so re-import is forced),
|
||||
-- onEditSave(version, slotId) (host handler for the Edit affordance on a save
|
||||
@@ -542,13 +543,18 @@ function RomImporter.new(onComplete, opts)
|
||||
CacheFs.prefix = saved
|
||||
self.returning[version] =
|
||||
(not ready) and marker ~= nil and marker ~= markerFor(version)
|
||||
self.romName[version] = "pokemon_" .. info.id .. ".gb"
|
||||
self.romName[version] = "pokemon_" .. info.id
|
||||
.. (info.id == "yellow" and ".gbc" or ".gb")
|
||||
end
|
||||
|
||||
-- Android: import a save-dir .gb that is not yet ready (USB drop or a
|
||||
-- Android: import a save-dir .gb/.gbc that is not yet ready (USB drop or a
|
||||
-- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped
|
||||
-- so a stale picked_rom.gb cannot block the opposite version.
|
||||
if android and not (self.ready.red and self.ready.blue) then
|
||||
-- so a stale picked_rom.gb cannot block another version.
|
||||
local needRom = false
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
if not self.ready[version] then needRom = true; break end
|
||||
end
|
||||
if android and needRom then
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then self:startData(data, name) end
|
||||
end
|
||||
@@ -608,7 +614,7 @@ function RomImporter:focus(f)
|
||||
local version = self.androidPendingExportVersion or self:_savedropTarget()
|
||||
self.androidPendingExportVersion = nil
|
||||
self.saveNotice[version] = { ok = true, text = "Save exported." }
|
||||
if self.tab == "mods" or self.tab == "yellow" then self.tab = version end
|
||||
if self.tab == "mods" then self.tab = version end
|
||||
return
|
||||
end
|
||||
local modName = findPendingMod(false)
|
||||
@@ -629,9 +635,13 @@ function RomImporter:focus(f)
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.ready.red and self.ready.blue then return end
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then self:startData(data, name) end
|
||||
for _, v in ipairs(GameVersion.ORDER) do
|
||||
if not self.ready[v] then
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then self:startData(data, name) end
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:setError(message, version)
|
||||
@@ -660,7 +670,8 @@ local function resetPointerCursor(self)
|
||||
end
|
||||
|
||||
-- Verify + extract a ROM. The version is decided by the ROM's own SHA-1, so
|
||||
-- dropping a Red or Blue cart into either column always lands in the right one.
|
||||
-- dropping a Red, Blue, or Yellow cart into any column always lands in the
|
||||
-- right one.
|
||||
function RomImporter:startData(data, displayName)
|
||||
if self.workState == "working" then return end
|
||||
if type(data) ~= "string" then
|
||||
@@ -676,14 +687,14 @@ function RomImporter:startData(data, displayName)
|
||||
local version = GameVersion.forSha1(actualHash)
|
||||
if not version then
|
||||
self:setError(("Unsupported ROM (SHA-1 %s). Use an unmodified US Pokemon "
|
||||
.. "Red or Blue ROM."):format(actualHash))
|
||||
.. "Red, Blue, or Yellow ROM."):format(actualHash))
|
||||
return
|
||||
end
|
||||
local info = GameVersion.info(version)
|
||||
|
||||
-- Bring the launcher to this version's tab so its progress bar is on screen
|
||||
-- (a dropped cart is routed by SHA-1 regardless of which tab was showing).
|
||||
if self.tab == "red" or self.tab == "blue" or self.tab == "yellow" then
|
||||
if GameVersion.VERSIONS[self.tab] then
|
||||
self.tab = version
|
||||
end
|
||||
self.importing = version
|
||||
@@ -731,7 +742,7 @@ function RomImporter:startData(data, displayName)
|
||||
self.returning[version] = false
|
||||
self.romName[version] = (displayName
|
||||
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
|
||||
-- Android: drop the consumed save-dir .gb (picked_rom.gb or a USB copy)
|
||||
-- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy)
|
||||
-- so the next Choose / focus cannot treat it as a fresh pending ROM.
|
||||
if self.android and type(displayName) == "string"
|
||||
and not displayName:find("[/\\]") then
|
||||
@@ -845,12 +856,12 @@ function RomImporter:chooseMod()
|
||||
end
|
||||
|
||||
-- Which game a dropped .sav imports into: a .sav has no version signature of
|
||||
-- its own, so it lands on the active game tab. When a non-game tab (mods, or
|
||||
-- the locked yellow placeholder) is showing, default to red -- the always-
|
||||
-- present first game -- rather than guess.
|
||||
-- its own, so it lands on the active game tab. When a non-game tab (mods) is
|
||||
-- showing, default to red -- the always-present first game -- rather than
|
||||
-- guess.
|
||||
function RomImporter:_savedropTarget()
|
||||
local v = self.tab
|
||||
if v == "red" or v == "blue" then return v end
|
||||
if GameVersion.VERSIONS[v] then return v end
|
||||
return "red"
|
||||
end
|
||||
|
||||
@@ -861,8 +872,7 @@ end
|
||||
-- playable with its game's data present.
|
||||
function RomImporter:_importSave(version, source)
|
||||
if self.workState == "working" then return end
|
||||
if self.tab == "red" or self.tab == "blue" or self.tab == "mods"
|
||||
or self.tab == "yellow" then
|
||||
if GameVersion.VERSIONS[self.tab] or self.tab == "mods" then
|
||||
self.tab = version
|
||||
end
|
||||
if not self.ready[version] then
|
||||
@@ -971,8 +981,8 @@ function RomImporter:choose(version)
|
||||
if self.workState == "working" then return end
|
||||
self.chooseVersion = version or "red"
|
||||
if self.android then
|
||||
-- Prefer a not-yet-imported .gb already in the save dir (USB copy, or a
|
||||
-- fresh SAF pick). Never reuse an already-imported cart's file -- that
|
||||
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
|
||||
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that
|
||||
-- was the #167 failure mode (second Choose just re-extracted Red).
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then
|
||||
@@ -995,8 +1005,8 @@ function RomImporter:choose(version)
|
||||
return
|
||||
end
|
||||
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or
|
||||
-- kdialog. Fall back to the same "drop a .gb next to the game" scan used
|
||||
-- on Android, which works when the game is launched as an unpacked
|
||||
-- kdialog. Fall back to the same "drop a .gb/.gbc next to the game" scan
|
||||
-- used on Android, which works when the game is launched as an unpacked
|
||||
-- directory (see build-rg34xxsp.sh).
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then
|
||||
@@ -1010,13 +1020,13 @@ function RomImporter:choose(version)
|
||||
or "the game folder"
|
||||
self.notice = {
|
||||
version = self.chooseVersion,
|
||||
status = "No file picker. Copy your .gb into:",
|
||||
status = "No file picker. Copy your .gb/.gbc into:",
|
||||
detail = where,
|
||||
}
|
||||
return
|
||||
end
|
||||
if love.system.getOS() ~= "OS X" and love.system.getOS() ~= "Windows" then
|
||||
self:setError("File selection is unavailable here. Drop the .gb file onto the window.")
|
||||
self:setError("File selection is unavailable here. Drop the .gb/.gbc file onto the window.")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1112,7 +1122,7 @@ function RomImporter:_updatePadCursor(dt)
|
||||
local next = (self.modScroll or 0) + step
|
||||
self.modScroll = math.max(0, math.min(maxS, next))
|
||||
end
|
||||
elseif self.tab == "red" or self.tab == "blue" then
|
||||
elseif GameVersion.VERSIONS[self.tab] then
|
||||
local maxS = (self._slotMax and self._slotMax[self.tab]) or 0
|
||||
if maxS > 0 then
|
||||
local next = (self.slotScroll[self.tab] or 0) + step
|
||||
@@ -1138,7 +1148,7 @@ function RomImporter:gamepadpressed(_, button)
|
||||
-- Start / Select: Play if ready, else Choose ROM on the active game tab.
|
||||
if self.workState == "working" then return end
|
||||
local version = self.tab
|
||||
if version == "red" or version == "blue" then
|
||||
if GameVersion.VERSIONS[version] then
|
||||
if self.ready[version] then self:play(version) else self:choose(version) end
|
||||
end
|
||||
end
|
||||
@@ -1972,9 +1982,9 @@ function RomImporter:keypressed(key)
|
||||
if self.workState == "working" then return end
|
||||
if key == "return" or key == "space" or key == "kpenter" then
|
||||
-- Enter acts on the visible game tab: Play if its ROM is ready, otherwise
|
||||
-- open its picker. The mods / placeholder tabs have no keyboard action.
|
||||
-- open its picker. The mods tab has no keyboard action.
|
||||
local version = self.tab
|
||||
if version == "red" or version == "blue" then
|
||||
if GameVersion.VERSIONS[version] then
|
||||
if self.ready[version] then self:play(version) else self:choose(version) end
|
||||
end
|
||||
end
|
||||
@@ -2132,7 +2142,7 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
|
||||
end
|
||||
cursorX = segEnd + gap
|
||||
end
|
||||
-- "N of 3 ready" (Red + Blue count; Yellow never ready), hidden if no room
|
||||
-- "N of 3 ready" (Red + Blue + Yellow once in GameVersion.ORDER)
|
||||
local ready = 0
|
||||
for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end
|
||||
love.graphics.setFont(self.readyFont)
|
||||
@@ -2152,9 +2162,12 @@ end
|
||||
function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
local s, pulse = self._s, self.pulse
|
||||
self.panelVersion = version
|
||||
local locked = version == "yellow"
|
||||
local info = (not locked) and GameVersion.info(version) or nil
|
||||
local gameName = locked and "Pokemon Yellow" or info.displayName
|
||||
-- Defensive: only lock when the version is absent from GameVersion (never
|
||||
-- solely because id == "yellow").
|
||||
local info = GameVersion.info(version)
|
||||
local locked = info == nil
|
||||
local gameName = info and (info.launcherName or info.displayName)
|
||||
or tostring(version)
|
||||
local ready = (not locked) and self.ready[version] or false
|
||||
|
||||
-- header: name + status pill
|
||||
@@ -2191,12 +2204,13 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
local rightX = twoCol and (x + colW + colGap) or x
|
||||
|
||||
-- ROM card contents by state (rehomes the existing import flow)
|
||||
local dropHint = self.android and "Copy the .gb via USB."
|
||||
or Strings("Or drop the .gb file here.")
|
||||
local accent = locked and PAL.gold or (version == "red" and PAL.red or PAL.blue)
|
||||
local dropHint = self.android and "Copy the .gb/.gbc via USB."
|
||||
or Strings("Or drop the .gb/.gbc file here.")
|
||||
local accent = version == "yellow" and PAL.gold
|
||||
or (version == "red" and PAL.red or PAL.blue)
|
||||
local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
|
||||
if locked then
|
||||
romState, romDetail = "Not supported yet", "Yellow support is on the way."
|
||||
romState, romDetail = "Not supported yet", "Support for this game is on the way."
|
||||
romBtnLabel, romBtnEnabled = "Import unavailable", false
|
||||
else
|
||||
local importing = self.importing == version
|
||||
@@ -2245,7 +2259,6 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
|
||||
-- SAVE FILES card: Import save is live once the ROM is imported (playable);
|
||||
-- Export save is live only when the active slot actually holds a save. The
|
||||
-- locked yellow placeholder has no save backend, so both stay disabled. The
|
||||
-- hint line doubles as the last import/export outcome (green ok / red error).
|
||||
local sfImportEnabled, sfExportEnabled = false, false
|
||||
if not locked then
|
||||
@@ -2358,9 +2371,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
self:_playButton(leftX, playY, colW, playH, gameName, ready, locked)
|
||||
|
||||
-- SAVE SLOT card (right column, or stacked below Play when single-column).
|
||||
-- The locked Yellow placeholder has no save backend (no GameVersion entry, so
|
||||
-- no slots can exist); skip the panel entirely rather than draw an empty,
|
||||
-- non-functional "+ New save slot" on a COMING SOON game.
|
||||
-- Skip only when the version is absent from GameVersion (no save backend).
|
||||
if not locked then
|
||||
if twoCol then
|
||||
self:_drawSaveSlotPanel(version, rightX, bodyTop, colW, bodyH)
|
||||
|
||||
Reference in New Issue
Block a user