yellow alpha

This commit is contained in:
bryanthaboi
2026-07-29 11:46:32 -04:00
parent d6e36d457f
commit dde25ec7d0
54 changed files with 55593 additions and 698 deletions
+17 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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)