Files
gen1recomp/src/import/RomExtractorGen2.lua
T

5909 lines
248 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- Gold Gen 2 extractor: pret/pokegold's ROM layout, parallel to
-- src/import/RomExtractor.lua (Gen 1) but never branched into it -- the two
-- generations disagree on almost everything below the constants layer
-- (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 ImageWriter = require("src.import.ImageWriter")
local LuaWriter = require("src.import.LuaWriter")
local Rom = require("src.import.Rom")
-- engine/gfx/load_pics.asm FixPicBank. `dba_pic` does NOT store the real
-- bank for the three "Pics" sections that live above the 8-bit-friendly range:
-- it writes $13, $14 or $1f and the loader maps them back on the way out.
-- A reader that trusts the stored byte lands in the wrong bank entirely, which
-- is why nine of the Unown letters decoded as noise and the rest as nothing.
local FIX_PIC_BANK = {
[0x13] = 0x1f, -- BANK("Pics 12")
[0x14] = 0x20, -- BANK("Pics 13")
[0x1f] = 0x2e, -- BANK("Pics 14")
}
local RomExtractorGen2 = {}
RomExtractorGen2.__index = RomExtractorGen2
-- constants, font, palettes, tilesets, maps, sprites, scripts+text, std
-- scripts, pokemon, moves, items, marts, encounters, trainers, pokedex,
-- landmarks, intro movie, menu gfx, title, credits, diploma, trade animation,
-- audio, stubs
local STAGE_COUNT = 26
local Opcodes = require("src.script.gen2.Opcodes")
-- BG palette slots inside one loaded 8-palette set (constants/tileset_constants.asm
-- PAL_BG_*); PAL_BG_ROOF is the one LoadMapPals overrides per map group.
local PAL_BG_NAMES = {
"GRAY", "RED", "GREEN", "WATER", "YELLOW", "BROWN", "ROOF", "TEXT",
}
local PAL_BG_ROOF = 6 -- 0-based slot index
-- Time-of-day palette sets, in wTimeOfDayPal order (MORN_F..DARKNESS_F).
local DAYTIMES = { "MORN", "DAY", "NITE", "DARK" }
-- gfx/tilesets/bg_tiles.pal: morn/day/nite/dark/indoor (8 each) plus the two
-- overworld-water palettes at $28-$29 -- "Valid indices: $00 - $29".
local BG_PALETTE_COUNT = 0x2a
-- gfx/overworld/npc_sprites.pal is PAL_OW_* x NUM_DAYTIMES.
local OW_PALETTE_COUNT = 8
-- data/maps/environment_colors.asm rows are 8 indices per daytime.
local ENV_POINTER_COUNT = 8 -- NUM_ENVIRONMENTS + 1 (row 0 is unused)
local MAP_GROUP_COUNT = 26 -- constants/map_constants.asm NUM_MAP_GROUPS
-- gfx/tileset_palette_maps.asm lives in "bank2" (main.asm) alongside
-- EnvironmentColorsPointers, and the Tilesets row only stores a 16-bit
-- pointer, so the bank has to come from here.
local PAL_MAP_BANK = 0x02
-- A tileset sheet is 96 tiles (128x48 at 8x8), and its PalMap packs two
-- tiles per byte: low nibble first tile, high nibble second (`dn` in the
-- tilepal macro). The high bit of each nibble is the VRAM bank, not colour.
local TILESET_TILE_COUNT = 96
-- Every Gen 2 back pic is 6x6 tiles (48x48); only front pics vary in size.
local BACK_PIC_TILES = 6
-- Gen 2 tilesets always ship 128 metatiles (16 tile ids each) and 128
-- collision quads (4 COLL_* bytes each) -- see gfx/tilesets/*_metatiles.bin.
local METATILE_COUNT = 128
local MAP_LENGTH = 9
local ATTR_LENGTH = 12
local CONNECTION_LENGTH = 12
local WARP_LENGTH = 5
local COORD_LENGTH = 8
local BG_LENGTH = 5
local OBJECT_LENGTH = 13
-- constants/script_constants.asm OBJECTTYPE_*: the object_event function byte
-- that says whether its pointer is bytecode, a `trainer` struct, or an item.
local OBJECTTYPE_ITEMBALL, OBJECTTYPE_TRAINER = 1, 2
-- constants/script_constants.asm BGEVENT_*: the bg_event function byte does the
-- same job for a sign's pointer. BGEVENT_ITEM is a HIDDEN ITEM and its operand
-- aims at `hiddenitem item, flag` (macros/scripts/maps.asm), not at bytecode --
-- 87 of them, and disassembling those three bytes as commands is where most of
-- the port's unknown-opcode rows came from.
local BGEVENT_ITEM = 7
-- constants/map_setup_constants.asm. `def_callbacks` asserts one callback per
-- type at most, so a count above this is a misread header rather than data.
local NUM_MAPCALLBACK_TYPES = 5
-- constants/script_constants.asm: the cmdqueue entry a `writecmdqueue` names
-- (dbw type, addr + dw filler) and the stonetable row it points at
-- (db warp, object + dw script), a `db -1` ending the list.
local CMDQUEUE_ENTRY_SIZE = 6
local CMDQUEUE_STONETABLE = 2
local STONETABLE_LENGTH = 4
-- MenuHeader (ram/wram.asm wMenuHeader): db flags; menu_coords lays the
-- corners down y-first (macros/coords.asm `db \2, \1` twice); dw the data
-- pointer; db the default cursor position.
local MENU_HEADER_LENGTH = 8
-- constants/npc_trade_constants.asm. The rsreset block ends on an `rb_skip`
-- padding byte, so the stride is 32 rather than the 31 the fields add up to --
-- the same "read the rsreset, not the macro" rule TrainerClassAttributes'
-- seven bytes came from. `dname` pads with '@', so each name field carries
-- its own terminator inside its 11 bytes.
local NPCTRADE_STRUCT_LENGTH = 32
local MON_NAME_LENGTH, NAME_LENGTH = 11, 11
local NUM_NPC_TRADES = 6
-- constants/script_constants.asm NUM_BUG_CONTESTANTS, "not counting the
-- player", which data/events/bug_contest_flags.asm asserts its length against.
local NUM_BUG_CONTESTANTS = 10
-- constants/phone_constants.asm PHONE_CONTACT_SIZE and SPECIALCALL_SIZE.
local PHONE_CONTACT_SIZE = 12
local SPECIALCALL_SIZE = 6
-- The WRAM string buffers a `text_ram` can name (ram/wram.asm, addresses from
-- pokegold.sym). Every one of them decodes to the same `{STRBUF}` marker --
-- this port has one shared buffer -- so the names are only recorded where a
-- caller asks, to tell two markers in one line apart.
local TEXT_BUFFERS = {
[0xcf48] = "wMonOrItemNameBuffer",
[0xcf6b] = "wStringBuffer1",
[0xcf7e] = "wStringBuffer2",
[0xcf91] = "wStringBuffer3",
[0xcfa4] = "wStringBuffer4",
[0xcfb7] = "wStringBuffer5",
-- The trade animation names the two trademon records instead
-- (data/text/common_1.asm _MonWasSentToText and the rest), and the four it
-- reaches for are the only way to tell "MACHOP was sent to MIKE" from the
-- line with the two names the other way round.
[0xc5d1] = "wPlayerTrademonSpeciesName",
[0xc5e7] = "wPlayerTrademonSenderName",
[0xc602] = "wOTTrademonSpeciesName",
[0xc618] = "wOTTrademonSenderName",
}
-- The text commands that print nothing and carry no argument
-- (macros/scripts/text.asm, in TextCommands order): TX_LOW, TX_SCROLL,
-- TX_PAUSE, TX_WAIT_BUTTON, TX_DAY, and the six TX_SOUND_* jingles.
local TEXT_NO_GLYPH = {
[0x05] = true, [0x07] = true, [0x0a] = true, [0x0b] = true, [0x0d] = true,
[0x0e] = true, [0x0f] = true, [0x10] = true, [0x11] = true, [0x12] = true,
[0x13] = true, [0x15] = true,
}
local ROOF_TILES = 9
local SPRITEDATA_LENGTH = 6
-- data/sprites/sprites.asm overworld_sprite type / palette bytes.
local WALKING_SPRITE, STANDING_SPRITE, STILL_SPRITE = 1, 2, 3
local SPRITE_TYPE_NAME = {
[WALKING_SPRITE] = "WALKING_SPRITE",
[STANDING_SPRITE] = "STANDING_SPRITE",
[STILL_SPRITE] = "STILL_SPRITE",
}
local SPRITE_PALETTE_NAME = {
[0] = "PAL_OW_RED", [1] = "PAL_OW_BLUE", [2] = "PAL_OW_GREEN",
[3] = "PAL_OW_BROWN", [4] = "PAL_OW_PINK", [5] = "PAL_OW_EMOTE",
[6] = "PAL_OW_TREE", [7] = "PAL_OW_ROCK",
}
-- Connection flag bits (constants/map_data_constants.asm shift_const order).
local CONN_EAST, CONN_WEST, CONN_SOUTH, CONN_NORTH = 0x01, 0x02, 0x04, 0x08
local function copy(value)
if type(value) ~= "table" then return value end
local result = {}
for key, item in pairs(value) do result[copy(key)] = copy(item) end
return result
end
function RomExtractorGen2.new(romData, manifest, progress)
return setmetatable({
rom = Rom.new(romData),
manifest = manifest,
symbols = manifest.symbols,
progress = progress,
stage = 0,
}, RomExtractorGen2)
end
function RomExtractorGen2:symbol(name)
local location = self.symbols[name]
if not location then error("required symbol is missing: " .. tostring(name)) end
return { bank = location[1], address = location[2], name = name }
end
-- A headless import shows nothing but a spinner, so a stage that dies takes
-- its own name down with it. POKEPORT_IMPORT_TRACE=1 prints each stage as it
-- starts, which is what turns "the import hung" into "the import hung in
-- Pokemon".
local TRACE = os.getenv("POKEPORT_IMPORT_TRACE") == "1"
function RomExtractorGen2:beginStage(name)
self.stage = self.stage + 1
if TRACE then
io.write(("[gold import] %d/%d %s\n"):format(self.stage, STAGE_COUNT, name))
io.flush()
end
if self.progress then self.progress(self.stage - 1, STAGE_COUNT, name, 0, 1) end
end
-- Sub-stage trace: the same POKEPORT_IMPORT_TRACE switch, for the places
-- inside a stage where a bad pointer could send a terminator-driven walk into
-- the weeds.
function RomExtractorGen2:trace(message)
if not TRACE then return end
io.write(("[gold import] %s\n"):format(message))
io.flush()
end
function RomExtractorGen2:tick(name, current, total)
if self.progress then
self.progress(self.stage - 1 + current / total, STAGE_COUNT,
name, current, total)
end
end
function RomExtractorGen2:write(name, value)
LuaWriter.write("data/generated/" .. name .. ".lua", value)
end
function RomExtractorGen2:save(image, relative)
ImageWriter.save(image, "assets/generated/" .. relative)
end
-- Pics and tileset graphics are lz3-compressed (home/decompress.asm); the
-- terminator ($ff) is what actually ends the stream, so -- same trick as
-- Gen 1's writeCompressedPic -- it is safe to just hand over everything to
-- the end of the bank rather than track an exact compressed length.
function RomExtractorGen2:decompressLz3Symbol(label)
local symbol = self:symbol(label)
local compressed = self.rom:bytes(
symbol.bank, symbol.address, 0x8000 - symbol.address)
return Rom.decompressLz3(compressed)
end
function RomExtractorGen2:write2bpp(raw, width, height, relative, transparent)
local image = ImageWriter.decode2bpp(raw, width, height, transparent)
self:save(image, relative)
end
-- Decompresses a lz3 Pokemon/trainer pic and writes it at its native size
-- (BASE_PIC_SIZE low nibble, tiles wide/tall -- Gen 2 pics are always
-- square). pret builds these with `rgbgfx --columns`, so the 2bpp stream
-- is column-major; ImageWriter.columnsToRows puts them back into a normal
-- top-to-bottom PNG (same as Gen 1's interleave handling).
function RomExtractorGen2:writeCompressedPic(label, tiles, relative)
local pixels = self:decompressLz3Symbol(label)
local size = tiles * 8
local byteLength = size * size / 4
while #pixels < byteLength do pixels[#pixels + 1] = 0 end
while #pixels > byteLength do table.remove(pixels) end
pixels = ImageWriter.columnsToRows(pixels, tiles, tiles)
-- pokegold engine/battle/core.asm GetTrainerBackpic: no hardware masking,
-- so matte the white backdrop like Gen 1's writeCompressedPic does.
self:save(ImageWriter.matteColor0(
ImageWriter.decode2bpp(pixels, size, size)), relative)
end
function RomExtractorGen2:extractConstants()
self:beginStage("Game constants")
local data = copy(self.manifest.constants)
data.generation = 2
self:write("constants", data)
self:tick("Game constants", 1, 1)
return data
end
-- GBC colour. Gen 2 is a CGB-native game: everything on screen is drawn
-- through eight 4-colour BG palettes plus eight OBJ palettes, so unlike Gen 1
-- (where SGB palette *zones* tint a fundamentally 4-shade image) the colours
-- here are not decoration -- a tile's palette is part of its identity.
--
-- engine/gfx/color.asm LoadMapPals is the whole overworld colour pipeline:
-- 1. EnvironmentColorsPointers[wEnvironment] -> a table of 4 rows (one per
-- time of day), each 8 bytes. Each byte is an index into the shared
-- TilesetBGPalette pool, and lands in BG palette slot 0-7.
-- 2. MapObjectPals[wTimeOfDayPal] -> the 8 OBJ palettes for OW sprites
-- (each sprite's PAL_OW_* comes from data/sprites/sprites.asm).
-- 3. Outdoors only, RoofPals[wMapGroup] overwrites PAL_BG_ROOF colours 1
-- and 2, which is what makes each town's roofs a different colour while
-- sharing one roof tile sheet.
--
-- Colours are stored 0-255 per channel to match Gen 1's palettes.lua, so a
-- reader can hand either generation's table to the same shader.
local function scale5(value) return math.floor(value * 255 / 31 + 0.5) end
-- RGBDS's `percent` macro stores N% as 255*N/100, so $ff is 100% and $e6 is
-- 90%. Move accuracy and effect chance both go through it; converting back
-- here keeps moves.lua reading like Gen 1's (accuracy 90, not 230).
local function percentOf(raw)
return math.floor((raw or 0) * 100 / 255 + 0.5)
end
-- ITEMMENU_* (constants/item_data_constants.asm) is NOT contiguous: NOUSE is 0,
-- then `const_skip 3`, then CURRENT 4, PARTY 5, CLOSE 6. A positional list
-- cannot be indexed by value, so the enum is spelled out with its real numbers.
local ITEM_MENU_NAME = {
[0] = "ITEMMENU_NOUSE",
[4] = "ITEMMENU_CURRENT",
[5] = "ITEMMENU_PARTY",
[6] = "ITEMMENU_CLOSE",
}
-- Font is 1bpp in Gen 2 (gfx/font.asm); FontExtra/FontBattleExtra are 2bpp.
-- TextBox / Font.drawCode multiply by the current color, so sheets MUST be
-- black ink on transparent (Gen 1's shape) -- opaque white+black becomes a
-- solid black rectangle when drawn with color (0,0,0).
--
-- Textbox borders ┌─┐│└┘ live in Frames (1bpp, loaded at $79), NOT FontExtra
-- (engine/gfx/load_font.asm LoadFrame). We composite frame 0 into imageExtra.
local TEXTBOX_FRAME_TILES = 6
local NUM_FRAMES = 8
local function inkFrom1bpp(raw, width, height)
local image = ImageWriter.blank(width, height, 0, 0, 0, 0)
local tilesPerRow = width / 8
for tile = 0, #raw / 8 - 1 do
local tileX, tileY = tile % tilesPerRow * 8, math.floor(tile / tilesPerRow) * 8
for y = 0, 7 do
local row = raw[tile * 8 + y + 1]
for x = 0, 7 do
if bit.band(row, 2 ^ (7 - x)) ~= 0 then
image:setPixel(tileX + x, tileY + y, 0, 0, 0, 1)
end
end
end
end
return image
end
local function inkFrom2bpp(raw, width, height)
local shaded = ImageWriter.decode2bpp(raw, width, height)
local image = ImageWriter.blank(width, height, 0, 0, 0, 0)
for y = 0, height - 1 do
for x = 0, width - 1 do
local r = shaded:getPixel(x, y)
if r < 0.5 then image:setPixel(x, y, 0, 0, 0, 1) end
end
end
return image
end
-- NUM_UNOWN + 1 tiles (the letters plus the cursor), on pret's own 3-wide
-- sheet; see the block in extractFont below.
local UNOWN_FONT_TILES = 27
local UNOWN_FONT_WIDE = 3
function RomExtractorGen2:extractFont()
self:beginStage("Fonts")
local font = self:symbol("Font")
local raw = self.rom:bytes(font.bank, font.address, 128 * 8)
self:save(inkFrom1bpp(raw, 128, 64), "fonts/font.png")
self:tick("Fonts", 1, 4)
-- Extra page ($60+): FontExtra 2bpp ink, then Frames borders at $79-$7E.
local extra = self:symbol("FontExtra")
local extraImg = inkFrom2bpp(
self.rom:bytes(extra.bank, extra.address, 128 * 16 / 4), 128, 16)
local frames = self:symbol("Frames")
-- gfx/font.asm:10 Frames: NUM_FRAMES rows of TEXTBOX_FRAME_TILES 1bpp tiles,
-- which is what LoadFrame's AddNTimes indexes off wTextboxFrame.
local frameRaw = self.rom:bytes(
frames.bank, frames.address, NUM_FRAMES * TEXTBOX_FRAME_TILES * 8)
local frameSheet = inkFrom1bpp(frameRaw,
TEXTBOX_FRAME_TILES * 8, NUM_FRAMES * 8)
self:save(frameSheet, "fonts/frames.png")
-- Row 0 still bakes into the extra page, so a cache without the sheet keeps
-- frame 1 exactly as before.
for t = 0, TEXTBOX_FRAME_TILES - 1 do
local destId = 0x79 + t - 0x60 -- tile index in the $60-based extra sheet
local dx, dy = (destId % 16) * 8, math.floor(destId / 16) * 8
ImageWriter.blit(extraImg, frameSheet, dx, dy, t * 8, 0, 8, 8)
end
-- _LoadFontsExtra (engine/gfx/load_font.asm:7-20) does NOT lay FontExtra
-- down from $60. It writes three sources, and only the third is FontExtra:
--
-- $60-$61 FontsExtra_SolidBlackAndUpArrowGFX, 2 tiles, Get1bpp
-- $62 PokegearPhoneIconGFX, 1 tile, Get2bpp
-- $63+ FontExtra + 3 tiles, 22 tiles, Get2bpp
--
-- The third line is why the page above is still right from $63 up: FontExtra
-- tile n lands at $60 + n either way, so only the first three cells differ.
-- Left alone they hold FontExtra's own <BOLD_A>/<BOLD_B>/<BOLD_C>, which the
-- cart never shows -- constants/charmap.asm:41 marks the $62 one "unused"
-- and :88 gives $62 to "☎" -- and that is exactly the bold C the Pokegear's
-- caller box drew where the phone icon belongs.
--
-- Both are tolerated rather than required, like UnownFont below: a manifest
-- built before these symbols were listed still imports and simply keeps the
-- three unused letters.
if self.symbols["FontsExtra_SolidBlackAndUpArrowGFX"] then
local solid = self:symbol("FontsExtra_SolidBlackAndUpArrowGFX")
-- gfx/font/black.1bpp then gfx/font/up_arrow.1bpp, one tile each
local solidImg = inkFrom1bpp(
self.rom:bytes(solid.bank, solid.address, 2 * 8), 16, 8)
ImageWriter.blit(extraImg, solidImg, 0, 0, 0, 0, 8, 8) -- $60
ImageWriter.blit(extraImg, solidImg, 8, 0, 8, 0, 8, 8) -- $61
end
if self.symbols["PokegearPhoneIconGFX"] then
local phone = self:symbol("PokegearPhoneIconGFX")
local phoneImg = inkFrom2bpp(
self.rom:bytes(phone.bank, phone.address, 8 * 8 / 4), 8, 8)
ImageWriter.blit(extraImg, phoneImg, 16, 0, 0, 0, 8, 8) -- $62
end
self:save(extraImg, "fonts/font_extra.png")
self:tick("Fonts", 2, 4)
local battleExtra = self:symbol("FontBattleExtra")
self:save(inkFrom2bpp(
self.rom:bytes(battleExtra.bank, battleExtra.address, 128 * 16 / 4),
128, 16), "fonts/font_battle_extra.png")
self:tick("Fonts", 3, 5)
local data = {
generation = 2,
source = "ROM:Font, FontExtra, Frames, FontBattleExtra, PokegearPhoneIconGFX, FontsExtra_SolidBlackAndUpArrowGFX",
image = "assets/generated/fonts/font.png",
imageExtra = "assets/generated/fonts/font_extra.png",
imageBattleExtra = "assets/generated/fonts/font_battle_extra.png",
imageFrames = "assets/generated/fonts/frames.png",
frameBase = 0x79, frameTiles = TEXTBOX_FRAME_TILES,
mainBase = 0x80, extraBase = 0x60, glyphsPerRow = 16,
charmap = self.manifest.fontCharmap or {},
}
-- The Unown font (gfx/font/unown_font.2bpp, gfx/font.asm UnownFont). It is
-- not a page of the ordinary font: Pokedex_LoadUnownFont copies 27 tiles to
-- vTiles2 tile FIRST_UNOWN_CHAR ($40) only while the #DEX's UNOWN MODE is
-- up, so the letters are addressed as tiles rather than as characters and
-- this is written as a SHEET (four shades, drawn through a palette) rather
-- than as ink like the three pages above.
--
-- Tile n is letter n + 1 (A..Z) and tile 26 -- FIRST_UNOWN_CHAR + NUM_UNOWN
-- -- is the diamond cursor the ring of letters is pointed at with. pret's
-- PNG is 3 tiles across and 9 down and the 2bpp is built without
-- --columns, so a straight row-major 24x72 decode IS gfx/font/
-- unown_font.png, byte for byte.
--
-- The sheet goes out UNINVERTED, the way it sits in the ROM: the routine's
-- Pokedex_InvertTiles pass is the same flip Chrome.printInverted already
-- applies to the ordinary font (the palette read backwards), so inverting
-- here would only make the drawing side undo it.
--
-- Tolerated rather than required, like credits and the diploma: a manifest
-- built before this symbol was listed still imports, it just leaves UNOWN
-- MODE printing its letters in the ordinary font.
if self.symbols["UnownFont"] then
local unown = self:symbol("UnownFont")
self:write2bpp(
self.rom:bytes(unown.bank, unown.address, UNOWN_FONT_TILES * 16),
UNOWN_FONT_WIDE * 8, UNOWN_FONT_TILES / UNOWN_FONT_WIDE * 8,
"fonts/unown_font.png")
data.imageUnown = "assets/generated/fonts/unown_font.png"
data.unownTiles = UNOWN_FONT_TILES
data.unownWide = UNOWN_FONT_WIDE
data.unownBase = 0x40 -- FIRST_UNOWN_CHAR
data.source = data.source .. ", UnownFont"
end
self:tick("Fonts", 4, 5)
self:write("font", data)
self:tick("Fonts", 5, 5)
return data
end
-- One GBC colour word: little-endian BGR555 (bits 0-4 red, 5-9 green,
-- 10-14 blue).
function RomExtractorGen2:color(bank, address)
local value = self.rom:word(bank, address)
return {
scale5(value % 32),
scale5(math.floor(value / 32) % 32),
scale5(math.floor(value / 1024) % 32),
}
end
-- `count` consecutive colours starting at address.
function RomExtractorGen2:colors(bank, address, count)
local out = {}
for i = 0, count - 1 do
out[#out + 1] = self:color(bank, address + i * 2)
end
return out
end
-- BattleObjectPals (gfx/battle_anims/battle_anims.pal): SIX four-colour
-- palettes, and the first of them is PAL_BATTLE_OB_GRAY -- not slot 0. Slots
-- 0 and 1 are PAL_BATTLE_OB_ENEMY / PAL_BATTLE_OB_PLAYER, which
-- _CGB_BattleColors fills with the two battlers' own colours, so the block on
-- disk starts two slots in and `battleAnimObPaletteOrder` is indexed from
-- PAL_BATTLE_OB_GRAY onward here.
function RomExtractorGen2:battleObjectPals()
local names = (self.manifest.constants or {}).battleAnimObPaletteOrder or {}
-- Tolerated rather than required: a manifest built before this symbol was
-- listed still imports, it just leaves the runtime on its fallback ramp.
if not self.symbols["BattleObjectPals"] then return nil end
local symbol = self:symbol("BattleObjectPals")
local out = {}
-- The block's row 0 is PAL_BATTLE_OB_GRAY, which is index 3 of the 1-based
-- name list (ENEMY, PLAYER, GRAY, ...).
for row = 0, 5 do
local name = names[row + 3]
if name then
out[name] = self:colors(symbol.bank, symbol.address + row * 8, 4)
end
end
return out
end
-- A tileset's PalMap: 48 bytes, two tiles apiece. `tilepal` emits
-- `dn (bank | PAL_BG_second), (bank | PAL_BG_first)`, so the low nibble is
-- the even tile and the high nibble the odd one; masking to 3 bits drops the
-- OAM_BANK flag and leaves the PAL_BG_* slot. Returned 1-based so the value
-- indexes an 8-entry Lua palette set directly.
function RomExtractorGen2:readPalMap(address)
local raw = self.rom:bytes(PAL_MAP_BANK, address, TILESET_TILE_COUNT / 2)
local out = {}
for i, byte in ipairs(raw) do
out[(i - 1) * 2 + 1] = byte % 8 + 1
out[(i - 1) * 2 + 2] = math.floor(byte / 16) % 8 + 1
end
return out
end
function RomExtractorGen2:extractPalettes()
self:beginStage("Color palettes")
local consts = self.manifest.constants
-- The shared pool every BG palette slot is filled from.
local bgSymbol = self:symbol("TilesetBGPalette")
local bg = {}
for index = 0, BG_PALETTE_COUNT - 1 do
bg[index + 1] = self:colors(bgSymbol.bank, bgSymbol.address + index * 8, 4)
end
self:tick("Color palettes", 1, 6)
-- environment -> daytime -> 8 pool indices (stored 1-based to index `bg`).
local envSymbol = self:symbol("EnvironmentColorsPointers")
local environments = {}
local environmentOrder = consts.environmentOrder or {}
for slot = 0, ENV_POINTER_COUNT - 1 do
local rowAddress = self.rom:word(
envSymbol.bank, envSymbol.address + slot * 2)
-- Slot 0 is the unused leading entry; environment ids are 1-based.
local name = environmentOrder[slot]
if name then
local perDaytime = {}
for day = 0, #DAYTIMES - 1 do
local indices = {}
for i = 0, 7 do
indices[i + 1] = self.rom:byte(
envSymbol.bank, rowAddress + day * 8 + i) + 1
end
perDaytime[DAYTIMES[day + 1]] = indices
end
environments[name] = perDaytime
end
end
self:tick("Color palettes", 2, 6)
-- OW sprite OBJ palettes, one set of 8 per time of day.
local objSymbol = self:symbol("MapObjectPals")
local objects = {}
for day = 0, #DAYTIMES - 1 do
local set = {}
for pal = 0, OW_PALETTE_COUNT - 1 do
set[pal + 1] = self:colors(objSymbol.bank,
objSymbol.address + (day * OW_PALETTE_COUNT + pal) * 8, 4)
end
objects[DAYTIMES[day + 1]] = set
end
self:tick("Color palettes", 3, 6)
-- RoofPals rows are `table_width COLOR_SIZE * 2 * 2`: two morn/day colours
-- followed by two nite colours, copied over PAL_BG_ROOF colours 1-2.
local roofSymbol = self:symbol("RoofPals")
local roofs = {}
for group = 0, MAP_GROUP_COUNT do
local base = roofSymbol.address + group * 8
roofs[group] = {
mornDay = self:colors(roofSymbol.bank, base, 2),
nite = self:colors(roofSymbol.bank, base + 4, 2),
}
end
self:tick("Color palettes", 4, 6)
-- Mon and trainer pics ship only their two middle colours; white and black
-- bracket them (data/pokemon/palettes.asm "only the middle two colors").
local monSymbol = self:symbol("PokemonPalettes")
local pokemon = {}
for index, species in ipairs(consts.speciesOrder or {}) do
if species and species ~= "UNUSED" then
local base = monSymbol.address + index * 8
pokemon[species] = {
normal = self:colors(monSymbol.bank, base, 2),
shiny = self:colors(monSymbol.bank, base + 4, 2),
}
end
end
-- The table runs past the 251 species: data/pokemon/palettes.asm:530 gives
-- EGG ($fd, constants/pokemon_constants.asm:271) a row of its own, which is
-- why `assert_table_length EGG + 1` sits right under it. _CGB_Evolution
-- reaches it through GetPlayerOrMonPalettePointer (engine/gfx/color.asm:620)
-- whenever a pic's species is EGG, which is what Hatch_LoadFrontpicPal and
-- the egg stats screen both hand it.
pokemon.EGG = {
normal = self:colors(monSymbol.bank, monSymbol.address + 253 * 8, 2),
shiny = self:colors(monSymbol.bank, monSymbol.address + 253 * 8 + 4, 2),
}
self:tick("Color palettes", 5, 6)
local trainerSymbol = self:symbol("TrainerPalettes")
local trainers = {}
for index, class in ipairs(consts.trainerClassOrder or {}) do
-- Row 0 is PlayerPalette (Chris shares Cal's colours); TRAINER_NONE has
-- no pic of its own, so name that row PLAYER instead.
local name = (index == 1) and "PLAYER" or class
trainers[name] = self:colors(
trainerSymbol.bank, trainerSymbol.address + (index - 1) * 4, 2)
end
local hpSymbol = self:symbol("HPBarPals")
local expSymbol = self:symbol("ExpBarPalette")
local partySymbol = self:symbol("PartyMenuOBPals")
local data = {
generation = 2,
source = "ROM:TilesetBGPalette/EnvironmentColorsPointers/MapObjectPals/RoofPals",
daytimes = { DAYTIMES[1], DAYTIMES[2], DAYTIMES[3], DAYTIMES[4] },
slotNames = PAL_BG_NAMES,
roofSlot = PAL_BG_ROOF + 1,
bg = bg,
environments = environments,
objects = objects,
roofs = roofs,
pokemon = pokemon,
trainers = trainers,
-- Two colours per HP-bar state (green/yellow/red plus the unused blue).
hpBar = {
green = self:colors(hpSymbol.bank, hpSymbol.address, 2),
yellow = self:colors(hpSymbol.bank, hpSymbol.address + 4, 2),
red = self:colors(hpSymbol.bank, hpSymbol.address + 8, 2),
blue = self:colors(hpSymbol.bank, hpSymbol.address + 12, 2),
},
expBar = self:colors(expSymbol.bank, expSymbol.address, 2),
partyMenu = {
self:colors(partySymbol.bank, partySymbol.address, 4),
self:colors(partySymbol.bank, partySymbol.address + 8, 4),
},
-- The six animation-object palettes, keyed by the PAL_BATTLE_OB_* name so
-- an object's palette byte resolves straight through
-- battleAnimObPaletteOrder. Slots 0 and 1 (ENEMY / PLAYER) are the two
-- battlers' own colours and are not in this block.
battleObjects = self:battleObjectPals(),
}
self:write("palettes", data)
self:tick("Color palettes", 6, 6)
return data
end
-- The functions a `tileframe` row can name (data/tileset_anims.asm's macro:
-- `dw argument` then `dw function`), reverse-mapped by address so a tileset's
-- Anim pointer decodes into named steps instead of raw bank $3f addresses.
local ANIM_FUNCTIONS = {
"DoneTileAnimation", "WaitTileAnimation",
"StandingTileFrame", "StandingTileFrame8",
"AnimateWaterTile", "AnimateFlowerTile", "AnimateWaterPalette",
"ReadTileToAnimBuffer", "WriteTileFromAnimBuffer",
"ScrollTileRightLeft", "ScrollTileDown", "ScrollTileUp",
"ScrollTileLeft", "ScrollTileRight", "AnimateWhirlpoolTile",
"AnimateLavaBubbleTile1", "AnimateLavaBubbleTile2",
"AnimateTowerPillarTile", "FlickeringCaveEntrancePalette",
}
local ANIM_BANK = 0x3f
local ANIM_MAX_FRAMES = 32
local VTILES2 = 0x9000
-- How many tiles of each shared strip its animation function can index:
-- whirlpool `and %11` (engine/tilesets/tileset_anims.asm:368), tower pillar's
-- 0..4 offsets table (:334-342), lava `and %011` (:245).
local ANIM_STRIP_FRAMES = { whirlpool = 4, tower = 5, lava = 4 }
-- The two functions whose `tileframe` argument is a `dw vTiles2 tile, dw
-- frames` pair in bank $3f rather than a VRAM address (:290, :350).
local ANIM_POINTER_KIND = {
AnimateWhirlpoolTile = "whirlpool",
AnimateTowerPillarTile = "tower",
}
-- One shared frame strip, written once however many tilesets name it: the
-- source tiles stacked into an 8x(n*8) sheet, same shape as water_frames.png.
function RomExtractorGen2:animStrip(strips, name, kind, bank, address)
if not strips[name] then
local count = ANIM_STRIP_FRAMES[kind]
local rel = "tilesets/anim/" .. name .. ".png"
self:write2bpp(self.rom:bytes(bank, address, count * 16), 8, count * 8, rel)
strips[name] = { image = "assets/generated/" .. rel, frames = count }
end
return strips[name]
end
-- One tileset's `wTilesetAnim` program. _AnimateTileset (engine/tilesets/
-- tileset_anims.asm:11) runs ONE row per frame and DoneTileAnimation (:48)
-- wraps the index, so the row count IS the frames per pass.
function RomExtractorGen2:readTilesetAnim(address, byAddress, strips)
if not (address and address > 0) then return nil end
local frames = {}
-- A ReadTileToAnimBuffer/ScrollTile*/WriteTileFromAnimBuffer run (:399, :65,
-- :139, :386) scrolls the tileset's OWN tile, so the write row carries how
-- far one pass moves it instead of naming a strip.
local pending = nil
for index = 0, ANIM_MAX_FRAMES - 1 do
local at = address + index * 4
if at + 3 >= 0x8000 then break end
local arg = self.rom:word(ANIM_BANK, at)
local func = self.rom:word(ANIM_BANK, at + 2)
local name = byAddress[func]
if not name then return nil end
local frame = { func = name }
-- A vTiles2 argument is the VRAM tile the step writes; a wTileAnimBuffer
-- one is WRAM and has no tile id.
if arg >= VTILES2 and arg < VTILES2 + 0x800 then
frame.tile = math.floor((arg - VTILES2) / 16)
end
local kind = ANIM_POINTER_KIND[name]
if kind then
local dest = self.rom:word(ANIM_BANK, arg)
if dest >= VTILES2 and dest < VTILES2 + 0x800 then
frame.tile = math.floor((dest - VTILES2) / 16)
local strip = self:animStrip(strips,
("%s_%02x"):format(kind, frame.tile), kind,
ANIM_BANK, self.rom:word(ANIM_BANK, arg + 2))
frame.sheet, frame.frames = strip.image, strip.frames
end
elseif name == "AnimateLavaBubbleTile1" or name == "AnimateLavaBubbleTile2"
then
-- Both take no argument: tile $5b and tile $38, one strip (:254, :279).
frame.tile = (name == "AnimateLavaBubbleTile1") and 0x5b or 0x38
local lava = self.symbols["LavaBubbleTileFrames"]
if lava then
local strip = self:animStrip(
strips, "lava", "lava", lava[1], lava[2])
frame.sheet, frame.frames = strip.image, strip.frames
end
elseif name == "ReadTileToAnimBuffer" then
pending = { h = 0, v = 0 }
elseif name == "ScrollTileRightLeft" then
if pending then pending.h = pending.h + 1 end
elseif name == "ScrollTileDown" then
if pending then pending.v = pending.v + 1 end
elseif name == "ScrollTileUp" then
if pending then pending.v = pending.v - 1 end
elseif name == "WriteTileFromAnimBuffer" then
if pending and frame.tile then frame.scroll = pending end
pending = nil
end
frames[#frames + 1] = frame
if name == "DoneTileAnimation" then
return { period = #frames, frames = frames }
end
end
return nil
end
-- Every Tilesets row is TILESET_LENGTH (15) bytes: dba GFX, dba Meta,
-- dba Coll, dw Anim, dw NULL, dw PalMap (data/tilesets.asm's `tileset`
-- macro). Row 0 is the unused "Tileset0" alias of TilesetJohto, and row
-- index == the TILESET_* constant value, so tilesetOrder[n]'s row starts at
-- headers.address + n*15. GFX is lz3; Meta and Coll are raw.
function RomExtractorGen2:extractTilesets()
self:beginStage("World tiles")
local order = self.manifest.constants.tilesetOrder
local headers = self:symbol("Tilesets")
local imageWidth, imageHeight = 128, 48
local byteLength = imageWidth * imageHeight / 4
-- A cache built from a manifest without the tileset_anims symbols simply
-- resolves nothing here, and every `anim` below comes out nil.
local animByAddress = {}
for _, label in ipairs(ANIM_FUNCTIONS) do
local location = self.symbols[label]
if location and location[1] == ANIM_BANK then
animByAddress[location[2]] = label
end
end
-- Whirlpool/tower-pillar/lava strips live in bank $3f, not in a tileset, so
-- one table dedupes them across every program that names them.
local animStrips = {}
local out = {}
for index, constName in ipairs(order) do
local rowAddress = headers.address + index * 15
local gfxBank = self.rom:byte(headers.bank, rowAddress)
local gfxAddress = self.rom:word(headers.bank, rowAddress + 1)
local metaBank = self.rom:byte(headers.bank, rowAddress + 3)
local metaAddress = self.rom:word(headers.bank, rowAddress + 4)
local collBank = self.rom:byte(headers.bank, rowAddress + 6)
local collAddress = self.rom:word(headers.bank, rowAddress + 7)
local animAddress = self.rom:word(headers.bank, rowAddress + 9)
local palMapAddress = self.rom:word(headers.bank, rowAddress + 13)
local compressed = self.rom:bytes(
gfxBank, gfxAddress, 0x8000 - gfxAddress)
local pixels = Rom.decompressLz3(compressed)
while #pixels < byteLength do pixels[#pixels + 1] = 0 end
while #pixels > byteLength do table.remove(pixels) end
local base = constName:lower():gsub("^tileset_", "")
self:write2bpp(pixels, imageWidth, imageHeight, "tilesets/" .. base .. ".png")
local metaRaw = self.rom:bytes(
metaBank, metaAddress, METATILE_COUNT * 16)
local blocks = {}
for offset = 1, #metaRaw, 16 do
local block = {}
for pos = offset, offset + 15 do block[#block + 1] = metaRaw[pos] end
blocks[#blocks + 1] = block
end
local collRaw = self.rom:bytes(
collBank, collAddress, METATILE_COUNT * 4)
local collision = {}
for offset = 1, #collRaw, 4 do
collision[#collision + 1] = {
collRaw[offset], collRaw[offset + 1],
collRaw[offset + 2], collRaw[offset + 3],
}
end
out[constName] = {
id = constName,
generation = 2,
source = ("ROM:Tilesets[%d]"):format(index),
header = self.rom:bytes(headers.bank, rowAddress, 15),
image = "assets/generated/tilesets/" .. base .. ".png",
imageWidth = imageWidth, imageHeight = imageHeight,
tilesPerRow = imageWidth / 8,
blocks = blocks,
collision = collision,
-- Anim callbacks live in bank $3f (data/tilesets.asm).
anim = self:readTilesetAnim(animAddress, animByAddress, animStrips),
palMap = { bank = PAL_MAP_BANK, address = palMapAddress },
-- Which of the eight loaded BG palettes each of the 96 tiles draws
-- with, 1-based into palettes.bg slots (see readPalMap).
tilePalettes = self:readPalMap(palMapAddress),
}
self:tick("World tiles", index, #order)
end
-- The two frame strips every tileset's water/flower step writes from
-- (engine/tilesets/tileset_anims.asm:194 and :225), four 8x8 tiles each,
-- stacked into one 8x32 sheet. BG tiles, so no colour-0 key.
local waterFrames = self.symbols["AnimateWaterTile.WaterTileFrames"]
if waterFrames then
self:write2bpp(self.rom:bytes(waterFrames[1], waterFrames[2], 4 * 16),
8, 32, "tilesets/water_frames.png")
out.waterFrames = "assets/generated/tilesets/water_frames.png"
end
local flowerFrames = self.symbols["AnimateFlowerTile.FlowerTileFrames"]
if flowerFrames then
self:write2bpp(self.rom:bytes(flowerFrames[1], flowerFrames[2], 4 * 16),
8, 32, "tilesets/flower_frames.png")
out.flowerFrames = "assets/generated/tilesets/flower_frames.png"
-- dmg_1, cgb_1, dmg_2, cgb_2: `and %10` plus hCGB picks rows 2 and 4
-- (tileset_anims.asm:204-212).
out.flowerCgbFrames = { 2, 4 }
end
-- Every shared strip the programs above named, for tests and mods; the anim
-- rows themselves already carry their own image path.
out.animFrames = animStrips
self:write("tilesets", out)
return out
end
function RomExtractorGen2:extractRoofs()
-- Outdoor Johto towns replace VRAM tiles $0a-$12 from Roofs, indexed by
-- MapGroupRoofs[group] (engine/tilesets/mapgroup_roofs.asm).
local roofs = self:symbol("Roofs")
local groupRoofs = self:symbol("MapGroupRoofs")
local out = { generation = 2, roofs = {}, mapGroupRoofs = {} }
-- Five roof sets (ROOF_NEW_BARK .. ROOF_GOLDENROD), 9 tiles each.
local roofNames = {
"NEW_BARK", "VIOLET", "AZALEA", "OLIVINE", "GOLDENROD",
}
for index, name in ipairs(roofNames) do
local addr = roofs.address + (index - 1) * ROOF_TILES * 16
local pixels = self.rom:bytes(roofs.bank, addr, ROOF_TILES * 16)
local rel = "tilesets/roofs/" .. name:lower() .. ".png"
self:write2bpp(pixels, 72, 8, rel)
out.roofs[name] = {
id = name, index = index - 1,
image = "assets/generated/" .. rel,
}
end
-- MapGroupRoofs: one signed byte per map group (groups are 1-based; the
-- table is indexed by group id, with a leading -1 for group 0).
for group = 1, 26 do
local value = self.rom:byte(groupRoofs.bank, groupRoofs.address + group)
if value < 0x80 and roofNames[value + 1] then
out.mapGroupRoofs[group] = roofNames[value + 1]
end
end
self:write("roofs", out)
return out
end
local function signedByte(value)
if value >= 0x80 then return value - 0x100 end
return value
end
local function orderName(list, index, fallback)
if type(list) ~= "table" then return fallback or index end
return list[index] or fallback or index
end
-- Resolve (group, map) -> const name from the manifest's mapGroups list.
function RomExtractorGen2:mapNameByIds(group, map)
if not self._mapByIds then
self._mapByIds = {}
for _, spec in ipairs(self.manifest.constants.mapGroups or {}) do
self._mapByIds[spec.group * 1000 + spec.map] = spec.name
end
end
return self._mapByIds[group * 1000 + map]
end
function RomExtractorGen2:readMapGroupEntry(group, map)
local pointers = self:symbol("MapGroupPointers")
local groupPtr = self.rom:word(
pointers.bank, pointers.address + (group - 1) * 2)
local entry = groupPtr + (map - 1) * MAP_LENGTH
local bank = pointers.bank
return {
attributesBank = self.rom:byte(bank, entry),
tileset = self.rom:byte(bank, entry + 1),
environment = self.rom:byte(bank, entry + 2),
attributesAddress = self.rom:word(bank, entry + 3),
landmark = self.rom:byte(bank, entry + 5),
music = self.rom:byte(bank, entry + 6),
phoneAndPalette = self.rom:byte(bank, entry + 7),
fishGroup = self.rom:byte(bank, entry + 8),
}
end
function RomExtractorGen2:readConnections(bank, address, flags)
local connections = {}
local dirs = {
{ bit = CONN_NORTH, key = "north" },
{ bit = CONN_SOUTH, key = "south" },
{ bit = CONN_WEST, key = "west" },
{ bit = CONN_EAST, key = "east" },
}
local cursor = address
for _, dir in ipairs(dirs) do
if bit.band(flags, dir.bit) ~= 0 then
local group = self.rom:byte(bank, cursor)
local map = self.rom:byte(bank, cursor + 1)
local yOffset = signedByte(self.rom:byte(bank, cursor + 8))
local xOffset = signedByte(self.rom:byte(bank, cursor + 9))
local offset
if dir.key == "north" or dir.key == "south" then
offset = -math.floor(xOffset / 2)
else
offset = -math.floor(yOffset / 2)
end
-- Avoid Lua's signed-zero (-0) from -math.floor(0/2).
if offset == 0 then offset = 0 end
connections[dir.key] = {
group = group, map = map,
mapId = self:mapNameByIds(group, map),
stripLength = self.rom:byte(bank, cursor + 6),
width = self.rom:byte(bank, cursor + 7),
yOffset = yOffset, xOffset = xOffset,
offset = offset,
}
cursor = cursor + CONNECTION_LENGTH
end
end
return connections
end
function RomExtractorGen2:readMapEvents(bank, address, spriteOrder)
-- *_MapEvents always starts with `db 0, 0 ; filler`.
local cursor = address + 2
local warpCount = self.rom:byte(bank, cursor)
cursor = cursor + 1
local warps = {}
for i = 1, warpCount do
local y = self.rom:byte(bank, cursor)
local x = self.rom:byte(bank, cursor + 1)
local destWarp = self.rom:byte(bank, cursor + 2)
local destGroup = self.rom:byte(bank, cursor + 3)
local destMap = self.rom:byte(bank, cursor + 4)
warps[i] = {
x = x, y = y, destWarp = destWarp,
destGroup = destGroup, destMapNum = destMap,
destMap = self:mapNameByIds(destGroup, destMap),
}
cursor = cursor + WARP_LENGTH
end
local coordCount = self.rom:byte(bank, cursor)
cursor = cursor + 1
local coordEvents = {}
for i = 1, coordCount do
coordEvents[i] = {
sceneId = self.rom:byte(bank, cursor),
y = self.rom:byte(bank, cursor + 1),
x = self.rom:byte(bank, cursor + 2),
script = self.rom:word(bank, cursor + 4),
}
cursor = cursor + COORD_LENGTH
end
local bgCount = self.rom:byte(bank, cursor)
cursor = cursor + 1
local bgEvents = {}
for i = 1, bgCount do
local kind = self.rom:byte(bank, cursor + 2)
local pointer = self.rom:word(bank, cursor + 3)
local ev = {
y = self.rom:byte(bank, cursor),
x = self.rom:byte(bank, cursor + 1),
kind = kind,
script = pointer,
}
-- BGEVENT_IFSET (5) and BGEVENT_IFNOTSET (6) do not point at a script.
-- They point at a `conditional_event` (dw event / dba script): two bytes of
-- EVENT id followed by a three-byte far pointer to the script proper.
-- `.ifset` / `.ifnotset` in engine/overworld/events.asm read the flag, and
-- only then `inc hl / inc hl / GetFarWord` to reach the real thing.
--
-- Extracted as a plain script pointer, those five bytes were disassembled
-- as commands and produced nonsense -- TeamRocketBaseB3F's locked door came
-- out as a lone `sjump` to an unrelated address. Nothing could run it, so
-- Giovanni's door never opened and the whole Rocket hideout dead-ended.
-- `MACRO conditional_event` is `dw \1, \2` -- two WORDS, the event then
-- the script address, both in the map's own bank. (Not a `dba`: the
-- handler's `inc hl / inc hl / GetFarWord` skips one word and reads the
-- next, and GetMapScriptsBank supplies the bank.)
if kind == 5 or kind == 6 then
ev.event = self.rom:word(bank, pointer)
ev.script = self.rom:word(bank, pointer + 2)
end
bgEvents[i] = ev
cursor = cursor + BG_LENGTH
end
local objectCount = self.rom:byte(bank, cursor)
cursor = cursor + 1
local objects = {}
for i = 1, objectCount do
local spriteId = self.rom:byte(bank, cursor)
local y = self.rom:byte(bank, cursor + 1) - 4
local x = self.rom:byte(bank, cursor + 2) - 4
local movement = self.rom:byte(bank, cursor + 3)
local radius = self.rom:byte(bank, cursor + 4)
local hour1 = signedByte(self.rom:byte(bank, cursor + 5))
local hour2 = signedByte(self.rom:byte(bank, cursor + 6))
local palType = self.rom:byte(bank, cursor + 7)
local sight = self.rom:byte(bank, cursor + 8)
local script = self.rom:word(bank, cursor + 9)
local eventFlag = self.rom:word(bank, cursor + 11)
objects[i] = {
index = i,
spriteId = spriteId,
sprite = orderName(spriteOrder, spriteId),
x = x, y = y,
movement = movement,
radius = { y = math.floor(radius / 16), x = radius % 16 },
hours = { hour1, hour2 },
palette = math.floor(palType / 16),
type = palType % 16,
sight = sight,
script = script,
eventFlag = eventFlag == 0xFFFF and nil or eventFlag,
}
cursor = cursor + OBJECT_LENGTH
end
return {
warps = warps, coordEvents = coordEvents,
bgEvents = bgEvents, objects = objects,
}
end
function RomExtractorGen2:extractMaps()
self:beginStage("Maps")
local consts = self.manifest.constants
local mapOrder = consts.mapOrder
local tilesetOrder = consts.tilesetOrder
local envOrder = consts.environmentOrder
local paletteOrder = consts.paletteOrder
local fishOrder = consts.fishGroupOrder
local spriteOrder = consts.spriteOrder
local out = {}
for index, name in ipairs(mapOrder) do
local spec = self.manifest.maps[name]
local entry = self:readMapGroupEntry(spec.group, spec.map)
local attrBank = entry.attributesBank
local attrAddr = entry.attributesAddress
local border = self.rom:byte(attrBank, attrAddr)
local height = self.rom:byte(attrBank, attrAddr + 1)
local width = self.rom:byte(attrBank, attrAddr + 2)
assert(width == spec.width and height == spec.height,
name .. ": attributes dims mismatch manifest")
local blocksBank = self.rom:byte(attrBank, attrAddr + 3)
local blocksAddr = self.rom:word(attrBank, attrAddr + 4)
local eventsBank = self.rom:byte(attrBank, attrAddr + 6)
local scriptsAddr = self.rom:word(attrBank, attrAddr + 7)
local eventsAddr = self.rom:word(attrBank, attrAddr + 9)
local connFlags = self.rom:byte(attrBank, attrAddr + 11)
local blocks = self.rom:bytes(
blocksBank, blocksAddr, width * height)
local connections = self:readConnections(
attrBank, attrAddr + ATTR_LENGTH, connFlags)
local events = self:readMapEvents(eventsBank, eventsAddr, spriteOrder)
local phonePalette = entry.phoneAndPalette
-- Map scripts header (macros/scripts/maps.asm): db scene_count;
-- scene_script {dw script, dw filler} × N; db callback_count;
-- callback {db type, dw script} × M. Scene 0 runs on map enter (Elm
-- walk-up, etc.); the callbacks are what RunMapCallback dispatches on a
-- load -- MAPCALLBACK_TILES repaints blocks, _OBJECTS moves NPCs,
-- _CMDQUEUE refills wCmdQueue (the two stone tables), _SPRITES swaps
-- sheets, _NEWMAP runs once per new game.
local sceneScripts, callbacks = {}, {}
if eventsBank and eventsBank > 0 and scriptsAddr and scriptsAddr >= 0x4000 then
local okCount, sceneCount = pcall(
self.rom.byte, self.rom, eventsBank, scriptsAddr)
if okCount and sceneCount and sceneCount < 32 then
for si = 0, sceneCount - 1 do
local addr = scriptsAddr + 1 + si * 4
local okSc, script = pcall(self.rom.word, self.rom, eventsBank, addr)
if okSc and script and script ~= 0 then
sceneScripts[si] = {
sceneId = si,
script = script,
scriptKey = Opcodes.key(eventsBank, script),
}
end
end
local cbBase = scriptsAddr + 1 + sceneCount * 4
local okCb, cbCount = pcall(self.rom.byte, self.rom, eventsBank, cbBase)
if okCb and cbCount and cbCount <= NUM_MAPCALLBACK_TYPES then
for ci = 0, cbCount - 1 do
local row = cbBase + 1 + ci * 3
local okType, kind = pcall(self.rom.byte, self.rom, eventsBank, row)
local okAddr, script = pcall(
self.rom.word, self.rom, eventsBank, row + 1)
if okType and okAddr and script and script ~= 0 then
-- MAPCALLBACK_* is `const_def 1` and mapCallbackOrder carries a
-- placeholder in front of it, so a type byte lands on Lua index
-- byte + 1 like every other 0-based order in this file.
local name = orderName(consts.mapCallbackOrder, kind + 1)
callbacks[#callbacks + 1] = {
type = kind,
callback = name,
script = script,
scriptKey = Opcodes.key(eventsBank, script),
}
end
end
end
end
end
out[name] = {
id = name,
generation = 2,
group = spec.group, map = spec.map,
width = width, height = height,
borderBlock = border,
tileset = orderName(tilesetOrder, entry.tileset),
tilesetId = entry.tileset,
environment = orderName(envOrder, entry.environment),
environmentId = entry.environment,
landmark = entry.landmark,
music = entry.music,
-- GetMapPhoneService (home/map.asm): the attribute byte's HIGH nybble,
-- and every caller tests `and a` -- ZERO means the map HAS service, so
-- the boolean is the nybble's emptiness. Caves and Kanto's dead zones
-- carry a non-zero nybble; towns and routes carry zero.
phoneService = math.floor(phonePalette / 16) == 0,
palette = orderName(paletteOrder, phonePalette % 16 + 1)
or (phonePalette % 16),
fishGroup = orderName(fishOrder, entry.fishGroup + 1)
or entry.fishGroup,
blocks = blocks,
-- Where those blocks live in ROM. `changemapblocks` hands the VM a raw
-- bank/pointer into blockdata and nothing else in the cache is keyed by
-- one, so World:blockdataAt places the pointer by walking these.
blockdata = { bank = blocksBank, address = blocksAddr },
connections = connections,
warps = events.warps,
coordEvents = events.coordEvents,
bgEvents = events.bgEvents,
objects = events.objects,
sceneScripts = sceneScripts,
callbacks = callbacks,
scripts = { bank = eventsBank, address = scriptsAddr },
events = { bank = eventsBank, address = eventsAddr },
source = ("ROM:MapGroupPointers[%d][%d]"):format(spec.group, spec.map),
}
self:tick("Maps", index, #mapOrder)
end
self:extractRoofs()
self:write("maps", out)
return out
end
-- OverworldSprites rows are NUM_SPRITEDATA_FIELDS (6) bytes each
-- (data/sprites/sprites.asm): dw addr, db length, bank, type, palette.
-- Length is already in bytes (`N tiles` in RGBDS). WALKING_SPRITE sheets
-- store standing + walking halves back-to-back (see ChrisSpriteGFX + 12
-- tiles), so ROM length is size*2; STANDING/STILL use size as-is. Layout
-- matches Gen 1's 16-wide strips that SpriteRenderer already understands
-- (stand down/up/left, walk down/up/left; right = X-flip).
--
-- Only the first `numOverworldSprites` ids are rows of that table; the ids from
-- SPRITE_POKEMON on are SpriteMons rows instead (extractMonSprites below), and
-- reading them out of OverworldSprites would decode whatever data follows it.
function RomExtractorGen2:extractSprites()
self:beginStage("Overworld sprites")
local consts = self.manifest.constants
local order = consts.spriteOrder
local rows = consts.numOverworldSprites or #order
local table = self:symbol("OverworldSprites")
local out, written = {}, {}
for index = 1, rows do
local constName = order[index]
local rowAddr = table.address + (index - 1) * SPRITEDATA_LENGTH
local pointer = self.rom:word(table.bank, rowAddr)
local sizeBytes = self.rom:byte(table.bank, rowAddr + 2)
local bank = self.rom:byte(table.bank, rowAddr + 3)
local spriteType = self.rom:byte(table.bank, rowAddr + 4)
local palette = self.rom:byte(table.bank, rowAddr + 5)
local byteLength = sizeBytes
if spriteType == WALKING_SPRITE then
byteLength = sizeBytes * 2
end
local width = 16
assert(byteLength > 0 and byteLength % 16 == 0,
constName .. ": sprite length not tile-aligned")
local height = byteLength * 4 / width
assert(height % 16 == 0, constName .. ": sprite height not frame-aligned")
local frames = height / 16
local base = constName:lower():gsub("^sprite_", "")
if not written[base] then
self:write2bpp(self.rom:bytes(bank, pointer, byteLength),
width, height, "sprites/" .. base .. ".png", true)
written[base] = true
end
out[constName] = {
id = constName,
source = ("ROM:OverworldSprites[%d]"):format(index - 1),
image = "assets/generated/sprites/" .. base .. ".png",
frames = frames,
walker = spriteType == WALKING_SPRITE or frames >= 6,
spriteType = SPRITE_TYPE_NAME[spriteType] or spriteType,
palette = SPRITE_PALETTE_NAME[palette] or palette,
paletteId = palette,
}
self:tick("Overworld sprites", index, #order)
end
self:extractMonSprites(out)
self:write("sprites", out)
return out
end
-- The mon-doll half of the sprite ids (data/sprites/sprite_mons.asm).
--
-- SpriteMons is a `table_width 1` list of species, one per id from
-- SPRITE_POKEMON ($80) up, and GetMonSprite (engine/overworld/overworld.asm)
-- is what makes it a sprite: its .Icon arm subtracts SPRITE_POKEMON, reads the
-- species out of SpriteMons and hands it to LoadOverworldMonIcon, which is
-- ReadMonMenuIcon + IconPointers -- i.e. the mon's PARTY MENU icon, eight
-- tiles, no sheet of its own. So the rows here point at the icon sheets
-- extractIcons already writes rather than at a second copy of them.
--
-- One frame, not two. _DoesSpriteHaveFacings sends everything from
-- SPRITE_POKEMON up to .only_down, and a doll is a still object that never
-- steps, so the only OAM set it ever uses is FacingStepDown0 -- tiles $00..$03,
-- the icon's FIRST frame (data/sprites/facings.asm). The second frame is the
-- party menu's bob and never reaches the map.
--
-- Palette 0 because _GetSpritePalette answers `xor a` for every mon sprite,
-- which is PAL_OW_RED in the MapObjectPals set.
function RomExtractorGen2:extractMonSprites(out)
local consts = self.manifest.constants
local order = consts.spriteOrder
local first = consts.spritePokemon
if not first or first > #order then return out end
local spriteMons = self:symbol("SpriteMons")
local monIcons = self:symbol("MonMenuIcons")
local iconOrder = consts.iconOrder or {}
local speciesOrder = consts.speciesOrder or {}
for index = first, #order do
local constName = order[index]
if constName and constName ~= "UNUSED" then
local row = index - first
local species = self.rom:byte(spriteMons.bank, spriteMons.address + row)
-- MonMenuIcons is 0-based on species-1, the same shift extractIcons uses.
local iconId = self.rom:byte(
monIcons.bank, monIcons.address + (species - 1))
local icon = iconOrder[iconId + 1]
assert(icon, constName .. ": no ICON_* name for icon " .. iconId)
local base = icon:lower():gsub("^icon_", "")
out[constName] = {
id = constName,
source = ("ROM:SpriteMons[%d]"):format(row),
image = "assets/generated/icons/gen2/" .. base .. ".png",
frames = 1,
walker = false,
spriteType = "POKEMON_SPRITE",
palette = SPRITE_PALETTE_NAME[0],
paletteId = 0,
species = speciesOrder[species],
icon = icon,
}
end
self:tick("Overworld sprites", index, #order)
end
return out
end
-- BaseData rows are BASE_DATA_SIZE (32) bytes, and -- unlike Gen 1's Kanto
-- reorder -- the row index already IS the dex number (data/pokemon/
-- base_stats.asm lists species in constants/pokemon_constants.asm order,
-- and each row's own first byte repeats that same dex number). Unown's base
-- data is read like everything else; only its PICS are special -- there is no
-- PokemonPicPointers row for it, because the 26 letters come out of
-- UnownPicPointers instead, and letter A's pics stand in for the species.
-- EvosAttacks: a per-species blob of 3-byte evolution rows terminated by 0,
-- then (level, move) pairs terminated by 0. Both halves are variable length,
-- so there is no row stride to index -- the terminators are the structure.
--
-- EVOLVE_STAT's second byte is a level and its *third* is the ATK_*_DEF
-- comparison, which is why that method takes four bytes where the rest take
-- three (data/pokemon/evos_attacks.asm's header comment).
local EVOLVE_LEVEL, EVOLVE_ITEM = 1, 2
local EVOLVE_TRADE, EVOLVE_HAPPINESS, EVOLVE_STAT = 3, 4, 5
-- EVOLVE_HAPPINESS parameter (constants/pokemon_data_constants.asm TR_*).
-- Both of these blocks are `const_def 1`, so the stored byte is 1-based and
-- the table must be too: a [0]-based one shifts every row down and drops the
-- last to nil, which silently made TR_NITE and ATK_EQ_DEF unreachable.
local TR_NAMES = { "ANYTIME", "MORNDAY", "NITE" }
-- EVOLVE_STAT comparison. The ROM order is GT, LT, EQ -- not the order the
-- method reads in, which is the easy way to get this pair backwards.
local ATK_NAMES = { "ATK_GT_DEF", "ATK_LT_DEF", "ATK_EQ_DEF" }
function RomExtractorGen2:readEvosAttacks(bank, address)
local moveOrder = self.manifest.constants.moveOrder or {}
local itemOrder = self.manifest.constants.itemOrder or {}
local evolutions, levelMoves = {}, {}
local pc = address
for _ = 1, 16 do
local method = self.rom:byte(bank, pc)
if method == 0 then pc = pc + 1 break end
if method == EVOLVE_STAT then
evolutions[#evolutions + 1] = {
method = "EVOLVE_STAT",
level = self.rom:byte(bank, pc + 1),
comparison = ATK_NAMES[self.rom:byte(bank, pc + 2)],
into = self:speciesName(self.rom:byte(bank, pc + 3)),
}
pc = pc + 4
else
local parameter = self.rom:byte(bank, pc + 1)
local into = self:speciesName(self.rom:byte(bank, pc + 2))
local row = { into = into }
if method == EVOLVE_LEVEL then
row.method, row.level = "EVOLVE_LEVEL", parameter
elseif method == EVOLVE_ITEM then
row.method, row.item = "EVOLVE_ITEM", itemOrder[parameter]
elseif method == EVOLVE_TRADE then
row.method = "EVOLVE_TRADE"
-- -1 means "no held item required".
row.item = (parameter ~= 0xff) and itemOrder[parameter] or nil
elseif method == EVOLVE_HAPPINESS then
row.method, row.time = "EVOLVE_HAPPINESS", TR_NAMES[parameter]
else
row.method, row.parameter = method, parameter
end
evolutions[#evolutions + 1] = row
pc = pc + 3
end
end
for _ = 1, 64 do
local level = self.rom:byte(bank, pc)
if level == 0 then break end
local move = self.rom:byte(bank, pc + 1)
levelMoves[#levelMoves + 1] = {
level = level,
move = moveOrder[move] or move,
}
pc = pc + 2
end
return evolutions, levelMoves
end
function RomExtractorGen2:extractPokemon()
self:beginStage("Pokemon")
local speciesOrder = self.manifest.constants.speciesOrder
local typeById = {}
for name, value in pairs(self.manifest.constants.types) do typeById[value] = name end
local baseData = self:symbol("BaseData")
local names = self:symbol("PokemonNames")
local evos = self:symbol("EvosAttacksPointers")
local growthRates = self:symbol("GrowthRates")
local tmhmMoves = self:symbol("TMHMMoves")
local eggMovePointers = self.symbols["EggMovePointers"]
and self:symbol("EggMovePointers") or nil
local growthOrder = self.manifest.constants.growthRateOrder or {}
local eggGroupOrder = self.manifest.constants.eggGroupOrder or {}
local moveOrder = self.manifest.constants.moveOrder or {}
local itemOrder = self.manifest.constants.itemOrder or {}
-- TMHMMoves maps a TM/HM number to the move it teaches; a species' BASE_TMHM
-- bitfield is indexed by that same number (see the tmhm macro), so decoding
-- it needs this table rather than a move id.
local tmhmList = {}
for i = 0, 63 do
local moveId = self.rom:byte(tmhmMoves.bank, tmhmMoves.address + i)
if moveId == 0 then break end
tmhmList[i + 1] = moveOrder[moveId] or moveId
end
-- GrowthRates rows: dn numerator, denominator; then the n^2, n and constant
-- terms, with a $80 sign bit on the n^2 term (data/growth_rates.asm).
local growth = {}
for index, name in ipairs(growthOrder) do
local base = growthRates.address + (index - 1) * 4
local packed = self.rom:byte(growthRates.bank, base)
local squared = self.rom:byte(growthRates.bank, base + 1)
local negative = squared >= 0x80
growth[name] = {
id = name, index = index - 1,
numerator = math.floor(packed / 16),
denominator = packed % 16,
squared = negative and -(squared - 0x80) or squared,
linear = self.rom:byte(growthRates.bank, base + 2),
constant = self.rom:byte(growthRates.bank, base + 3),
}
end
local out = { growthRates = growth, tmhmMoves = tmhmList }
for index, species in ipairs(speciesOrder) do
if species then
local row = self.rom:bytes(
baseData.bank, baseData.address + (index - 1) * 32, 32)
assert(row[1] == index, species .. ": base data dex mismatch")
local name = self.rom:decodeText(
self.rom:bytes(names.bank, names.address + (index - 1) * 10, 10),
self.manifest.charmap)
local asset = self.manifest.pokemonAssets[species]
local tiles = row[18] % 16 -- BASE_PIC_SIZE low nibble, tiles wide/tall
local front, back
if asset and asset.frontLabel then
self:writeCompressedPic(asset.frontLabel, tiles,
"battle/front/" .. asset.front .. ".png")
front = asset.front
end
if asset and asset.backLabel then
-- Back pics are ALWAYS 6x6 tiles (48x48). BASE_PIC_SIZE's low nibble
-- describes the *front* pic only -- Cyndaquil's front is 5x5 and Onix's
-- 7x7, but both backs are 48x48 (gfx/pokemon/*/back.png). Decoding a
-- back at the front's size reads the wrong number of tiles and lays
-- them out in the wrong number of columns, which comes out as garbage.
self:writeCompressedPic(asset.backLabel, BACK_PIC_TILES,
"battle/back/" .. asset.back .. ".png")
back = asset.back
end
-- EvosAttacksPointers is `table_width 2`, i.e. plain `dw` pointers, and
-- the blobs sit in the same bank as the table (main.asm's "Evolutions
-- and Attacks" section) -- there is no bank byte to read.
local evoAddress = self.rom:word(
evos.bank, evos.address + (index - 1) * 2)
local evolutions, levelMoves = self:readEvosAttacks(evos.bank, evoAddress)
-- Egg moves (data/pokemon/egg_moves.asm): EggMovePointers is a `dw` per
-- species into its OWN bank -- GetEggMove reads the pointer with
-- BANK(EggMovePointers) and then the list with BANK("Egg Moves"), and on
-- Gold those are the same bank. A species with none points at
-- NoEggMoves, which is a bare `db -1`, so it comes out as an empty list
-- rather than as nil: "this was extracted and there are none" is a
-- different claim from "this was never extracted".
local eggMoves = {}
if eggMovePointers then
local listAddr = self.rom:word(
eggMovePointers.bank, eggMovePointers.address + (index - 1) * 2)
for offset = 0, 15 do
local move = self.rom:byte(eggMovePointers.bank, listAddr + offset)
if move == 0xff then break end
eggMoves[#eggMoves + 1] = moveOrder[move] or move
end
end
-- BASE_TMHM is (NUM_TM_HM + 7) / 8 bytes, bit i of byte n meaning
-- TM/HM number n * 8 + i + 1 (the tmhm macro's layout).
local tmhmRaw = { row[25], row[26], row[27], row[28],
row[29], row[30], row[31], row[32] }
local tmhm = {}
for byteIndex, byteValue in ipairs(tmhmRaw) do
for bit = 0, 7 do
if math.floor(byteValue / 2 ^ bit) % 2 == 1 then
local number = (byteIndex - 1) * 8 + bit + 1
local move = tmhmList[number]
if move then tmhm[#tmhm + 1] = move end
end
end
end
out[species] = {
id = species, index = index, dex = index, name = name,
source = ("ROM:BaseData[%d]"):format(index),
baseStats = {
hp = row[2], attack = row[3], defense = row[4], speed = row[5],
specialAttack = row[6], specialDefense = row[7],
},
types = { typeById[row[8]] or row[8], typeById[row[9]] or row[9] },
catchRate = row[10], baseExp = row[11],
items = { itemOrder[row[12]], itemOrder[row[13]] },
-- Gender ratio is a raw threshold: a DV roll under it is female, so
-- GENDER_F0 = 0 is male-only and $fe (GENDER_F100) female-only.
genderRatio = row[14],
eggSteps = row[16],
picSize = row[18] % 16,
growthRateId = row[23],
growthRate = growthOrder[row[23] + 1],
-- One nibble per egg group (dn EGG_x, EGG_y).
eggGroups = {
eggGroupOrder[math.floor(row[24] / 16)],
eggGroupOrder[row[24] % 16],
},
eggGroupsRaw = row[24],
tmhmRaw = tmhmRaw,
tmhm = tmhm,
evolutions = evolutions,
levelMoves = levelMoves,
eggMoves = eggMoves,
spriteFront = front and ("assets/generated/battle/front/" .. front .. ".png") or nil,
spriteBack = back and ("assets/generated/battle/back/" .. back .. ".png") or nil,
}
end
self:tick("Pokemon", index, #speciesOrder)
end
-- UnownPicPointers: 26 rows of `dba_pics front, back` -- a three-byte far
-- pointer each -- one per letter. PokemonPicPointers' UNOWN row is only
-- the A form, so without this table the other twenty-five letters have no
-- pic at all. Both pics are the usual lz3 column-major blobs.
if self.symbols["UnownPicPointers"] and out.UNOWN then
local symbol = self:symbol("UnownPicPointers")
local letters = {}
local tiles = out.UNOWN.picSize or 6
for index = 0, 25 do
local letter = string.char(string.byte("A") + index)
local base = symbol.address + index * 6
local entry = {}
local function readPic(offset, size, folder, key)
local bank = FIX_PIC_BANK[self.rom:byte(symbol.bank, base + offset)]
or self.rom:byte(symbol.bank, base + offset)
local address = self.rom:word(symbol.bank, base + offset + 1)
local rel = ("battle/%s/unown_%s.png"):format(folder, letter:lower())
local ok, err = pcall(function()
-- GetLZByte bumps the bank and drops back to $4000 when the read
-- pointer passes $8000, so a pic near the top of its bank keeps
-- going into the next one.
local compressed = self.rom:bytes(bank, address, 0x8000 - address)
local nextBank = self.rom:bytes(bank + 1, 0x4000, 0x4000)
for _, byte in ipairs(nextBank) do
compressed[#compressed + 1] = byte
end
local pixels = Rom.decompressLz3(compressed)
local pixelSize = size * 8
local byteLength = pixelSize * pixelSize / 4
while #pixels < byteLength do pixels[#pixels + 1] = 0 end
while #pixels > byteLength do table.remove(pixels) end
pixels = ImageWriter.columnsToRows(pixels, size, size)
self:write2bpp(pixels, pixelSize, pixelSize, rel)
end)
if ok then
entry[key] = "assets/generated/" .. rel
else
self:trace(("unown %s %s: %s"):format(letter, key, tostring(err)))
end
end
readPic(0, tiles, "front", "spriteFront")
readPic(3, 6, "back", "spriteBack")
letters[letter] = entry
end
out.UNOWN.letters = letters
-- The species' own pics: letter A, which is what the cart shows when
-- nothing has picked a form yet (GetUnownLetter defaults to it).
out.UNOWN.spriteFront = out.UNOWN.spriteFront
or (letters.A and letters.A.spriteFront)
out.UNOWN.spriteBack = out.UNOWN.spriteBack
or (letters.A and letters.A.spriteBack)
end
self:write("pokemon", out)
return out
end
-- Title screen: TitleScreenGFX1 -> vTiles2 (BG ids $00-$7F), GFX2 ->
-- vTiles1 (ids $80+), then LoadTitleScreenTilemap streams
-- TitleScreenTilemap (gfx/title/logo.tilemap) into the BG map until $FF.
-- Same class of trap as Yellow -- never trust a naive atlas stack for the
-- on-screen image (see RomExtractor:extractYellowTitleArt). Ho-Oh is OBJ
-- (TitleScreenGFX4 @ vTiles0); trail tiles (GFX3) are animated OAM.
-- FillTitleScreenPals / title_bg_gold.pal colorize BG zones; title_fg.pal
-- tints Ho-Oh. Clouds (rows 11+) scroll via LYOverrides in retail.
function RomExtractorGen2:extractTitle()
self:beginStage("Title screen")
local function tilesFrom2bpp(raw, transparent)
local tiles = {}
for offset = 1, #raw - (#raw % 16), 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 function blitSprite(target, tile, tx, ty)
if not tile then return end
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = tile:getPixel(x, y)
if a ~= 0 then target:setPixel(tx + x, ty + y, r, g, b, a) end
end
end
end
local function shadeOf(r)
if r > 0.9 then return 0 end
if r > 0.5 then return 1 end
if r > 0.2 then return 2 end
return 3
end
-- pret gfx/title/title_bg_gold.pal / title_fg.pal (5 BG pals, 2 OBJ pals).
local BG_PALS = {
{ { 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).
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 },
}
local function palColor(pal, shade)
local c = pal[shade + 1] or pal[4]
return c[1] / 31, c[2] / 31, c[3] / 31, 1
end
-- LoadTitleScreenPals' non-CGB branch (engine/movie/title.asm) is what the
-- greyscale set has to be baked through, and Gold's registers are not the
-- identity: rBGP is %11011000, so the BG's colours 1 and 2 come out the
-- OTHER WAY ROUND from the shade the tile stores, and rOBP0 is %11111111 --
-- all four of Ho-Oh's colours map to shade 3, which is why the bird is a
-- 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 }
-- ImageWriter's four hardware shades, by shade number.
local DMG_SHADE = { 1, 2 / 3, 1 / 3, 0 }
-- Re-shade a decoded image through one of those registers, keeping whatever
-- alpha the decode gave it (an OBJ's colour 0 is transparent on hardware, so
-- it never reaches a palette register at all).
local function throughRegister(image, register)
local w, h = image:getDimensions()
local out = ImageWriter.blank(w, h, 0, 0, 0, 0)
for y = 0, h - 1 do
for x = 0, w - 1 do
local r, _, _, a = image:getPixel(x, y)
if a ~= 0 then
local v = DMG_SHADE[register[shadeOf(r) + 1] + 1]
out:setPixel(x, y, v, v, v, 1)
end
end
end
return out
end
-- FillTitleScreenPals (engine/movie/title.asm): pal 1 on rows 0-6,
-- pal 3 on the GOLD VERSION strip (row 6, cols 5-14), pal 4 on rows 12+.
local function bgPalAt(col, row)
if row >= 12 then return 4 end
if row == 6 and col >= 5 and col <= 14 then return 3 end
if row <= 6 then return 1 end
return 0
end
local function colorize(image, palFor)
local w, h = image:getDimensions()
local out = ImageWriter.blank(w, h, 0, 0, 0, 1)
for y = 0, h - 1 do
for x = 0, w - 1 do
local r, g, b, a = image:getPixel(x, y)
if a == 0 then
out:setPixel(x, y, 0, 0, 0, 0)
else
local pal = palFor(x, y)
local cr, cg, cb = palColor(pal, shadeOf(r))
out:setPixel(x, y, cr, cg, cb, 1)
end
end
end
return out
end
local vtiles2 = tilesFrom2bpp(self:decompressLz3Symbol("TitleScreenGFX1"))
local vtiles1 = tilesFrom2bpp(self:decompressLz3Symbol("TitleScreenGFX2"))
self:tick("Title screen", 1, 5)
local function tileFor(id)
if id < 0x80 then return vtiles2[id + 1] end
return vtiles1[id - 0x80 + 1]
end
-- TILEMAP_WIDTH = 32; visible screen is 20x18. Bytes stream linearly
-- into vBGMap until the $FF terminator (engine/movie/title.asm).
local map = self:symbol("TitleScreenTilemap")
local screen = ImageWriter.blank(160, 144, 1, 1, 1, 1)
local index = 0
while true do
local id = self.rom:byte(map.bank, map.address + index)
if id == 0xFF then break end
local col = index % 32
local row = math.floor(index / 32)
index = index + 1
if col < 20 and row < 18 then
local tile = tileFor(id)
if tile then ImageWriter.blit(screen, tile, col * 8, row * 8) end
end
end
local colored = colorize(screen, function(x, y)
return BG_PALS[bgPalAt(math.floor(x / 8), math.floor(y / 8)) + 1]
end)
self:save(colored, "title/title_screen.png")
-- ...and the same composition BEFORE colorize. Unlike every other Gen 2
-- sheet the title's are baked with their colours in, because the screen is
-- one tilemap with a hand-written per-region palette map rather than a
-- tileset the renderer can colour per tile. That leaves the COLOR option
-- nothing to substitute, so the grey source is kept alongside: under DMG and
-- CLASSIC it IS the picture, and under GBC it is simply unused. It goes
-- through rBGP rather than out of the decoder raw, or the logo's two middle
-- shades read inverted against the colour sheet.
local screenGray = throughRegister(screen, DMG_BGP)
self:save(screenGray, "title/title_screen_gray.png")
-- Cloud band for ScrollTitleScreenClouds (rows 11-16).
local clouds = ImageWriter.blank(160, 48, 0, 0, 0, 1)
ImageWriter.blit(clouds, colored, 0, 0, 0, 88, 160, 48)
self:save(clouds, "title/clouds.png")
local cloudsGray = ImageWriter.blank(160, 48, 0, 0, 0, 1)
ImageWriter.blit(cloudsGray, screenGray, 0, 0, 0, 88, 160, 48)
self:save(cloudsGray, "title/clouds_gray.png")
-- Logo strip (rows 0-6): readiness marker.
local logo = ImageWriter.blank(160, 56, 0, 0, 0, 1)
ImageWriter.blit(logo, colored, 0, 0, 0, 0, 160, 56)
self:save(logo, "title/pokemon_logo.png")
self:tick("Title screen", 2, 5)
-- Ho-Oh frames from OAMData_GSIntroHoOh1..5 (data/sprite_anims/oam.asm).
local hoohTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleScreenGFX4"), true)
local HOOH_FRAMES = {
{ -- 1
{ -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 },
{ -2, -3, 0, 0, 0x06 }, { -2, -1, 0, 0, 0x08 }, { -2, 1, 0, 0, 0x0a },
{ -1, -3, 0, 0, 0x0c }, { -1, -1, 0, 0, 0x0e }, { -1, 1, 0, 0, 0x10 },
{ 0, -3, 0, 0, 0x12 }, { 0, -1, 0, 0, 0x14 }, { 0, 1, 0, 0, 0x16 },
{ 1, -3, 0, 0, 0x18 }, { 1, -1, 0, 0, 0x1a }, { 1, 1, 0, 0, 0x1c },
{ 2, -1, 0, 0, 0x1e }, { 2, 1, 0, 0, 0x20 },
{ 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 },
},
{ -- 2
{ -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 },
{ -2, -1, 0, 0, 0x26 }, { -2, 1, 0, 0, 0x0a },
{ -1, -3, 0, 0, 0x28 }, { -1, -1, 0, 0, 0x2a }, { -1, 1, 0, 0, 0x10 },
{ 0, -1, 0, 0, 0x2c }, { 0, 1, 0, 0, 0x16 },
{ 1, -1, 0, 0, 0x30 }, { 1, 1, 0, 0, 0x1c },
{ 2, -1, 0, 0, 0x1e }, { 2, 1, 0, 0, 0x20 },
{ 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 },
},
{ -- 3
{ -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x32 },
{ -2, -1, 0, 0, 0x34 }, { -2, 1, 0, 0, 0x36 },
{ -1, -1, 0, 0, 0x38 }, { -1, 1, 0, 0, 0x3a },
{ 0, -1, 0, 0, 0x3c }, { 0, 1, 0, 0, 0x3e },
{ 1, -1, 0, 0, 0x30 }, { 1, 1, 0, 0, 0x1c },
{ 2, -1, 0, 0, 0x1e }, { 2, 1, 0, 0, 0x20 },
{ 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 },
},
{ -- 4
{ -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 },
{ -2, -1, 0, 0, 0x40 }, { -2, 1, 0, 0, 0x42 }, { -2, 3, 0, 0, 0x44 },
{ -1, -1, 0, 0, 0x46 }, { -1, 1, 0, 0, 0x48 }, { -1, 3, 0, 0, 0x4a },
{ 0, -1, 0, 0, 0x4c }, { 0, 1, 0, 0, 0x4e },
{ 1, -1, 0, 0, 0x30 }, { 1, 1, 0, 0, 0x1c },
{ 2, -1, 0, 0, 0x1e }, { 2, 1, 0, 0, 0x20 },
{ 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 },
},
{ -- 5
{ -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 },
{ -2, -1, 0, 0, 0x50 }, { -2, 1, 0, 0, 0x0a },
{ -1, -3, 0, 0, 0x52 }, { -1, -1, 0, 0, 0x54 }, { -1, 1, 0, 0, 0x10 },
{ 0, -3, 0, 0, 0x56 }, { 0, -1, 0, 0, 0x2e }, { 0, 1, 0, 0, 0x16 },
{ 1, -1, 0, 0, 0x30 }, { 1, 1, 0, 0, 0x1c },
{ 2, -1, 0, 0, 0x1e }, { 2, 1, 0, 0, 0x20 },
{ 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 = {
{ 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
-- 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)
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)
end
local tinted = colorize(pose, function() return OBJ_HOOH end)
local rel = ("title/hooh_%d.png"):format(fi)
self:save(tinted, rel)
hoohPaths[fi] = "assets/generated/" .. rel
-- ...and the same pose through rOBP0, which is the black silhouette.
local grayRel = ("title/hooh_%d_gray.png"):format(fi)
self:save(throughRegister(pose, DMG_OBP0), grayRel)
hoohGrayPaths[fi] = "assets/generated/" .. grayRel
if fi == 1 then
-- Readiness marker + older stubs expect hooh.png == frame 1.
self:save(tinted, "title/hooh.png")
end
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.
local trailSym = self:symbol("TitleScreenGFX3")
local trailRaw = self.rom:bytes(trailSym.bank, trailSym.address, 8 * 16)
local trailTiles = tilesFrom2bpp(trailRaw, true)
local trail = ImageWriter.blank(8, 16, 0, 0, 0, 0)
blitSprite(trail, trailTiles[1], 0, 0)
blitSprite(trail, trailTiles[2], 0, 8)
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")
self:tick("Title screen", 4, 5)
-- Copyright splash (data/copyright.asm PlaceString over CopyrightGFX).
local copyright = self:symbol("CopyrightGFX")
local copyRaw = self.rom:bytes(copyright.bank, copyright.address, 30 * 16)
local copyTiles = tilesFrom2bpp(copyRaw)
self:write2bpp(copyRaw, 240, 8, "title/copyright.png")
local copyLines = {
{ 0x60, 0x61, 0x62, 0x63, 0x7a, 0x7b, 0x7c, 0x7d,
0x65, 0x66, 0x67, 0x68, 0x69, 0x6a },
{ 0x60, 0x61, 0x62, 0x63, 0x7a, 0x7b, 0x7c, 0x7d,
0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72 },
{ 0x60, 0x61, 0x62, 0x63, 0x7a, 0x7b, 0x7c, 0x7d,
0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x71, 0x72 },
}
local splash = ImageWriter.blank(160, 144, 1, 1, 1, 1)
for li, line in ipairs(copyLines) do
local y = (7 + (li - 1)) * 8 -- hlcoord 2, 7 + next
local x = 2 * 8
for _, tid in ipairs(line) do
local tile = copyTiles[tid - 0x60 + 1]
if tile then ImageWriter.blit(splash, tile, x, y) end
x = x + 8
end
end
self:save(splash, "title/copyright_splash.png")
self:tick("Title screen", 5, 5)
local data = {
generation = 2,
layout = "gold_title",
source = "ROM:TitleScreenTilemap + TitleScreenGFX1/2/3/4, CopyrightGFX",
screen = "assets/generated/title/title_screen.png",
clouds = "assets/generated/title/clouds.png",
image = "assets/generated/title/pokemon_logo.png",
hooh = "assets/generated/title/hooh.png",
hoohFrames = hoohPaths,
-- The uncoloured set the COLOR option's DMG and CLASSIC modes draw.
screenGray = "assets/generated/title/title_screen_gray.png",
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.
hoohSequence = HOOH_SEQUENCE,
-- `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
-- WRONG. It builds `lb de, arg1 * 8, arg2 * 8`, and
-- _InitSpriteAnimStruct takes "x=e, y=d" -- so arg1 is the Y tile.
-- NamingScreen .InitCursor proves it: it overrides `d` alone to move
-- 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,
trail = "assets/generated/title/trail.png",
copyright = "assets/generated/title/copyright.png",
copyrightSplash = "assets/generated/title/copyright_splash.png",
-- ScrollTitleScreenClouds: 1px left every 8 frames (Gold).
cloudScrollEvery = 8,
cloudY = 88,
}
self:write("title", data)
return data
end
-- The credits roll's graphics (engine/movie/credits.asm). Four things come
-- out of the ROM here, and the shapes are all fixed by the load calls at the
-- top of `Credits::`:
--
-- CreditsBorderGFX 9 tiles -> vTiles2 $20. DrawCreditsBorder writes four
-- running ids five times across a row, starting at $24
-- for row 4 and $20 for row 13 -- so tiles 5-8 of the
-- strip are the TOP border and tiles 1-4 the bottom.
-- The 9th, $28, is the tile ConstructCreditsTilemap
-- ByteFills the whole screen with before anything else,
-- which makes it the banner's backdrop.
-- Credits<Mon>GFX 4x4-tile frames stacked: three for Bellossom, Togepi
-- and Elekid, four for Sentret (Credits_LoadBorderGFX's
-- .Frames offsets run to +48 tiles for Sentret alone).
-- pret builds these WITHOUT --columns, so the 2bpp
-- stream is already row-major over the whole sheet and
-- a 32 x (32 * frames) decode is the pret PNG.
-- TheEndGFX 16 tiles, its own "The End" section in gfx/misc.asm,
-- loaded to $40 and placed 8 wide on rows 8 and 9.
-- CreditsPalettes gfx/credits/credits.pal, six four-colour sets.
-- GetCreditsPalette masks the scene with %11, so only
-- the first four are reachable from the script, but all
-- six are written out because the block is one table.
--
-- Everything is tolerated rather than required: a manifest built before these
-- symbols were listed still imports, it just leaves the credits on the mon
-- icon fallback src/ui/gen2/Credits.lua carries.
local CREDITS_SCENES = {
{ species = "BELLOSSOM", label = "CreditsBellossomGFX", frames = 3 },
{ species = "TOGEPI", label = "CreditsTogepiGFX", frames = 3 },
{ species = "ELEKID", label = "CreditsElekidGFX", frames = 3 },
{ species = "SENTRET", label = "CreditsSentretGFX", frames = 4 },
}
local CREDITS_BORDER_TILES = 9
local CREDITS_THEEND_TILES = 16
function RomExtractorGen2:extractCredits()
self:beginStage("Credits")
local steps = #CREDITS_SCENES + 3
local data = {
generation = 2,
source = "ROM:CreditsBorderGFX + Credits<Mon>GFX + TheEndGFX"
.. " + CreditsPalettes",
}
if self.symbols["CreditsBorderGFX"] then
local border = self:symbol("CreditsBorderGFX")
self:write2bpp(self.rom:bytes(border.bank, border.address,
CREDITS_BORDER_TILES * 16), CREDITS_BORDER_TILES * 8, 8,
"credits/border.png")
data.border = "assets/generated/credits/border.png"
data.borderTiles = CREDITS_BORDER_TILES
-- 1-based tile columns of the strip, as DrawCreditsBorder's start ids.
data.borderTopTile = 5
data.borderBottomTile = 1
data.borderFillTile = 9
end
self:tick("Credits", 1, steps)
if self.symbols["TheEndGFX"] then
local theEnd = self:symbol("TheEndGFX")
self:write2bpp(self.rom:bytes(theEnd.bank, theEnd.address,
CREDITS_THEEND_TILES * 16), 64, 16, "credits/theend.png")
data.theEnd = "assets/generated/credits/theend.png"
-- Credits_TheEnd: hlcoord 6, 8 and 6, 9, eight tiles apiece.
data.theEndX, data.theEndY, data.theEndWidth = 6, 8, 8
end
self:tick("Credits", 2, steps)
local scenes = {}
for index, scene in ipairs(CREDITS_SCENES) do
if self.symbols[scene.label] then
local sym = self:symbol(scene.label)
local raw = self.rom:bytes(sym.bank, sym.address, scene.frames * 16 * 16)
local rel = ("credits/%s.png"):format(scene.species:lower())
self:write2bpp(raw, 32, 32 * scene.frames, rel)
scenes[index] = {
species = scene.species,
image = "assets/generated/" .. rel,
frames = scene.frames,
width = 32,
height = 32,
}
end
self:tick("Credits", 2 + index, steps)
end
if #scenes == #CREDITS_SCENES then data.scenes = scenes end
if self.symbols["CreditsPalettes"] then
local pal = self:symbol("CreditsPalettes")
local palettes = {}
for set = 0, 5 do
palettes[set + 1] = self:colors(pal.bank, pal.address + set * 8, 4)
end
data.palettes = palettes
end
self:tick("Credits", steps, steps)
self:write("credits", data)
return data
end
-- The #DEX diploma's page (engine/events/diploma.asm PlaceDiplomaOnScreen).
-- The certificate is not a text box: the routine decompresses DiplomaGFX into
-- vTiles2 and then CopyBytes' DiplomaPage1Tilemap straight over the whole
-- background before a single string is placed, so the border, the seal and
-- the ribbon are one 20x18 tilemap of cart art.
--
-- DiplomaGFX gfx/diploma/diploma.2bpp.lz, 112 tiles. pret
-- builds it WITHOUT --columns (gfx/lz.mk only sets
-- LZFLAGS), so the stream is row-major over the 16x7
-- sheet and a straight 128x56 decode IS the pret PNG.
-- DiplomaPage1Tilemap gfx/diploma/page1.tilemap, exactly SCREEN_AREA
-- bytes of tile ids into that sheet. Page 2 is the
-- Game Boy Printer's second sheet and is not read
-- here, the same way PrintDiploma stays stubbed.
-- DiplomaPalettes gfx/diploma/diploma.pal, eight four-colour sets.
-- _CGB_Diploma (engine/gfx/cgb_layouts.asm) loads all
-- eight and then WipeAttrmap zeroes the attrmap, so
-- set 0 is the one the whole screen actually draws
-- through; the rest are written out because the block
-- is one table.
--
-- Tolerated rather than required, like credits: a manifest built before these
-- three symbols were listed still imports, it just leaves src/ui/gen2/
-- Diploma.lua on its placeholder frame.
local DIPLOMA_TILES = 112
local DIPLOMA_SHEET_TILES = 16 -- tiles per row of the sheet
local DIPLOMA_SCREEN_W, DIPLOMA_SCREEN_H = 20, 18
local DIPLOMA_PALETTE_SETS = 8
function RomExtractorGen2:extractDiploma()
self:beginStage("Diploma")
local data = {
generation = 2,
source = "ROM:DiplomaGFX + DiplomaPage1Tilemap + DiplomaPalettes",
}
if self.symbols["DiplomaGFX"] then
local pixels = self:decompressLz3Symbol("DiplomaGFX")
local byteLength = DIPLOMA_TILES * 16
while #pixels < byteLength do pixels[#pixels + 1] = 0 end
while #pixels > byteLength do table.remove(pixels) end
self:write2bpp(pixels, DIPLOMA_SHEET_TILES * 8,
DIPLOMA_TILES / DIPLOMA_SHEET_TILES * 8, "diploma/diploma.png")
data.image = "assets/generated/diploma/diploma.png"
data.tiles = DIPLOMA_TILES
data.sheetTiles = DIPLOMA_SHEET_TILES
end
self:tick("Diploma", 1, 3)
if self.symbols["DiplomaPage1Tilemap"] then
local sym = self:symbol("DiplomaPage1Tilemap")
local raw = self.rom:bytes(sym.bank, sym.address,
DIPLOMA_SCREEN_W * DIPLOMA_SCREEN_H)
-- Kept flat, in the row-major order CopyBytes writes it, because that is
-- what the drawing side indexes with row * SCREEN_WIDTH + column.
data.page1 = raw
data.width = DIPLOMA_SCREEN_W
data.height = DIPLOMA_SCREEN_H
end
self:tick("Diploma", 2, 3)
if self.symbols["DiplomaPalettes"] then
local pal = self:symbol("DiplomaPalettes")
local palettes = {}
for set = 0, DIPLOMA_PALETTE_SETS - 1 do
palettes[set + 1] = self:colors(pal.bank, pal.address + set * 8, 4)
end
data.palettes = palettes
end
self:tick("Diploma", 3, 3)
self:write("diploma", data)
return data
end
-- The trade animation's art (engine/movie/trade_animation.asm, gfx/trade/).
-- Everything the cable-and-ball sequence draws that is not a frontpic or a
-- mon icon comes out of nine labels at the bottom of that file, and the
-- animation is a BACKGROUND that scrolls plus a handful of OAM objects, so
-- the two halves are extracted differently:
--
-- TradeGameBoyLZ gfx/trade/game_boy_cable.2bpp.lz, which the
-- Makefile builds as game_boy.2bpp (--remove-
-- duplicates) then link_cable.2bpp run together. It
-- decompresses to 49 tiles into vTiles2 tile $31, and
-- because Gen 2 runs the BG in $8800 mode a BG id of
-- N < $80 IS vTiles2 tile N: the two tilemaps below
-- and the loose cable ids the jumptable ByteFills
-- ($5b, $5d, $5f, $60, $61) all index this one sheet
-- from $31. Written flat with its base tile beside
-- it rather than re-split, since nothing on the
-- drawing side wants the Game Boy and the cable as
-- separate sheets.
-- TradeGameBoyTilemap gfx/trade/game_boy.tilemap, 6x8 ids, stamped at
-- hlcoord 3, 2 in state 0 and hlcoord 10, 6 in
-- state 2 (TradeAnim_CopyTradeGameBoyTilemap).
-- TradeLinkTubeTilemap gfx/trade/link_cable.tilemap, 12x3, stamped at
-- hlcoord 8, 2 by TradeAnim_EnterLinkTube1.
--
-- The objects are all mirrored quadrants, which is why so few tiles draw so
-- much (data/sprite_anims/oam.asm):
--
-- TradeBallGFX 6 tiles. Frame 1 is .OAMData_TradePokeBall1: tiles 0
-- and 1 are the ball's LEFT half and the right half is the
-- same two X-flipped. Frame 2 is .OAMData_MagnetTrainRed,
-- four distinct tiles in a plain 2x2. pret's ball.png is
-- 16x32 because it holds the blank right half of frame 1;
-- the 2bpp is built with --remove-whitespace, so the ROM
-- has 6 tiles and not 8 and this writes them as a strip
-- rather than inventing the two blanks back.
-- TradePoofGFX 12 tiles, three .OAMData_TradePoofBubble frames of 4;
-- each frame is a 2x2 quadrant mirrored into a 32x32
-- object, so the sheet is pret's own 16x48.
-- TradeCableGFX 2 tiles, one per .OAMData_TradeTubeBulge frame: the
-- bulge that travels inside the tube, one quadrant tile
-- mirrored into 16x16. Named "cable" in gfx/trade/ but
-- loaded at vTiles0 tile $74, which is where the bulge's
-- $12/$13 dictionary offsets land.
-- TradeBubbleGFX 4 tiles, the quadrant of the 32x32 bubble the mon icon
-- rides in (SPRITE_ANIM_OAMSET_TRADEMON_BUBBLE, offset
-- $10 off the $62 dictionary base).
-- TradeArrowRightGFX / TradeArrowLeftGFX
-- one BG tile each, ByteFilled six across at hlcoord 7, 2
-- of the window map by TradeAnim_PlaceTrademonStatsOnTube-
-- Anim to point the trade the way it is going. Two
-- symbols, one two-tile sheet.
--
-- Object tiles are written with colour 0 transparent, because that is what
-- colour 0 means to an OBJ; the BG sheets keep it as white.
--
-- Tolerated rather than required, like credits and the diploma: a manifest
-- built before these labels were listed still imports, it just leaves
-- src/ui/gen2/TradeAnim.lua drawing its placeholder shapes.
local TRADE_BASE_TILE = 0x31
local TRADE_SCENE_TILES = 49
local TRADE_SHEET_TILES = 7 -- 49 tiles is exactly 7x7, so nothing is padded
local TRADE_GAMEBOY_W, TRADE_GAMEBOY_H = 6, 8
local TRADE_TUBE_W, TRADE_TUBE_H = 12, 3
local TRADE_BALL_TILES = 6
local TRADE_POOF_TILES = 12
local TRADE_BULGE_TILES = 2
local TRADE_BUBBLE_TILES = 4
function RomExtractorGen2:extractTrade()
self:beginStage("Trade animation")
local data = {
generation = 2,
source = "ROM:TradeGameBoyLZ + TradeGameBoyTilemap + TradeLinkTubeTilemap"
.. " + TradeBallGFX + TradePoofGFX + TradeCableGFX + TradeBubbleGFX"
.. " + TradeArrowRightGFX + TradeArrowLeftGFX",
}
if self.symbols["TradeGameBoyLZ"] then
local pixels = self:decompressLz3Symbol("TradeGameBoyLZ")
local byteLength = TRADE_SCENE_TILES * 16
while #pixels < byteLength do pixels[#pixels + 1] = 0 end
while #pixels > byteLength do table.remove(pixels) end
self:write2bpp(pixels, TRADE_SHEET_TILES * 8, TRADE_SHEET_TILES * 8,
"trade/scene.png")
data.image = "assets/generated/trade/scene.png"
data.tiles = TRADE_SCENE_TILES
data.sheetTiles = TRADE_SHEET_TILES
-- The id a tilemap byte carries, minus this, is the tile's index into
-- the sheet above.
data.baseTile = TRADE_BASE_TILE
end
self:tick("Trade animation", 1, 3)
-- Both tilemaps stay flat, in the order TradeAnim_CopyBoxFromDEtoHL walks
-- them (row by row), because that is what the drawing side indexes with
-- row * width + column.
local function tilemap(label, width, height)
if not self.symbols[label] then return nil end
local sym = self:symbol(label)
return {
width = width,
height = height,
tiles = self.rom:bytes(sym.bank, sym.address, width * height),
}
end
data.gameBoy = tilemap("TradeGameBoyTilemap", TRADE_GAMEBOY_W,
TRADE_GAMEBOY_H)
data.tube = tilemap("TradeLinkTubeTilemap", TRADE_TUBE_W, TRADE_TUBE_H)
self:tick("Trade animation", 2, 3)
local function sprite(label, tiles, across, relative)
if not self.symbols[label] then return nil end
local sym = self:symbol(label)
local raw = self.rom:bytes(sym.bank, sym.address, tiles * 16)
self:write2bpp(raw, across * 8, tiles / across * 8, relative, true)
return { image = "assets/generated/" .. relative, tiles = tiles,
sheetTiles = across }
end
data.ball = sprite("TradeBallGFX", TRADE_BALL_TILES, 1, "trade/ball.png")
data.poof = sprite("TradePoofGFX", TRADE_POOF_TILES, 2, "trade/poof.png")
data.bulge = sprite("TradeCableGFX", TRADE_BULGE_TILES, 1, "trade/bulge.png")
data.bubble = sprite("TradeBubbleGFX", TRADE_BUBBLE_TILES, 2,
"trade/bubble.png")
-- The arrows are two one-tile symbols, kept in the order the port draws
-- them: index 0 sends, index 1 receives. They are background tiles, so
-- colour 0 stays white.
if self.symbols["TradeArrowRightGFX"] and self.symbols["TradeArrowLeftGFX"]
then
local right = self:symbol("TradeArrowRightGFX")
local left = self:symbol("TradeArrowLeftGFX")
local raw = self.rom:bytes(right.bank, right.address, 16)
for _, byte in ipairs(self.rom:bytes(left.bank, left.address, 16)) do
raw[#raw + 1] = byte
end
self:write2bpp(raw, 8, 16, "trade/arrows.png")
data.arrows = { image = "assets/generated/trade/arrows.png", tiles = 2,
sheetTiles = 1 }
end
self:tick("Trade animation", 3, 3)
self:write("trade", data)
return data
end
-- Dump Gen 2 audio banks and wire song/wave/drum metadata for ChipSynth's
-- generation-2 channel driver. Banks are NOT Gen 1's {2,8,31}.
function RomExtractorGen2:extractAudio(maps)
self:beginStage("Sound programs")
local bankOrder = { 0x07, 0x33, 0x3a, 0x3b, 0x3c, 0x3d }
local chunks = {}
for index, bank in ipairs(bankOrder) do
local first = Rom.offset(bank, 0x4000) + 1
chunks[index] = self.rom.data:sub(first, first + 0x3FFF)
self:tick("Sound programs", index, #bankOrder + 2)
end
local CacheFs = require("src.import.CacheFs")
local ok, writeError = CacheFs.write(
"assets/generated/audio/programs.bin", table.concat(chunks))
if not ok then
error("could not write audio programs: " .. tostring(writeError))
end
local musicOrder = self.manifest.constants.musicOrder or {}
local songs = {}
for _, name in ipairs(musicOrder) do
if name ~= "Music_Nothing" and self.symbols[name] then
local loc = self.symbols[name]
songs[name] = { bank = loc[1], address = loc[2], generation = 2 }
end
end
-- Prefer live Music: dba table when a label is missing from the sym embed.
local musicPtr = self:symbol("Music")
for index, name in ipairs(musicOrder) do
if name ~= "Music_Nothing" and not songs[name] then
local row = self.rom:bytes(
musicPtr.bank, musicPtr.address + (index - 1) * 3, 3)
songs[name] = {
bank = row[1],
address = row[2] + row[3] * 256,
generation = 2,
}
end
end
local wave = self:symbol("WaveSamples")
local drums = self:symbol("Drumkits")
local mapSongs = {}
if maps then
for mapId, def in pairs(maps) do
local id = def.music
if type(id) == "number" and id > 0 then
local label = musicOrder[id + 1]
if label and songs[label] then
mapSongs[mapId] = label
end
end
end
end
-- SFX pointer table (audio/sfx_pointers.asm): 3-byte dba per SFX_* id.
local sfxOrder = self.manifest.constants.sfxOrder or {}
local sfxPtr = self:symbol("SFX")
local sfx, fanfares = {}, {}
local FANFARE_NAMES = {
Sfx_CaughtMon = true, Sfx_Item = true,
Sfx_DexFanfare2049 = true, Sfx_DexFanfare5079 = true,
Sfx_DexFanfare80109 = true, Sfx_Fanfare = true,
}
for index, name in ipairs(sfxOrder) do
local row = self.rom:bytes(
sfxPtr.bank, sfxPtr.address + (index - 1) * 3, 3)
local def = {
bank = row[1],
address = row[2] + row[3] * 256,
generation = 2,
}
if FANFARE_NAMES[name] then def.fanfare = true end
sfx[name] = def
end
for name in pairs(FANFARE_NAMES) do
if sfx[name] then fanfares[name] = true end
end
-- Cry base headers (Cries:) + per-species PokemonCries (dw index,pitch,length).
local cryPtr = self:symbol("Cries")
local cryHeaders = {}
local NUM_CRIES = 68 -- pret NUM_CRIES
for i = 0, NUM_CRIES - 1 do
local row = self.rom:bytes(cryPtr.bank, cryPtr.address + i * 3, 3)
cryHeaders[i] = {
bank = row[1],
address = row[2] + row[3] * 256,
generation = 2,
}
end
local pokeCryPtr = self:symbol("PokemonCries")
local speciesOrder = self.manifest.constants.speciesOrder or {}
local cries = {}
for index, species in ipairs(speciesOrder) do
if species ~= "UNOWN" and not tostring(species):match("^UNUSED") then
local row = self.rom:bytes(
pokeCryPtr.bank, pokeCryPtr.address + (index - 1) * 6, 6)
local cryIndex = row[1] + row[2] * 256
local pitch = row[3] + row[4] * 256
local length = row[5] + row[6] * 256
local header = cryHeaders[cryIndex]
if header then
cries[species] = {
header = header,
pitch = pitch,
length = length,
}
end
end
end
local data = {
generation = 2,
runtime = true,
programFile = "assets/generated/audio/programs.bin",
bankOrder = bankOrder,
music = musicPtr,
songs = songs,
musicOrder = musicOrder,
mapSongs = mapSongs,
sfx = sfx,
sfxOrder = sfxOrder,
fanfares = fanfares,
cries = cries,
-- ChipSynth Gen 1 shape: one wave bank key; Gen 2 uses WaveSamples.
waveBanks = {
["1"] = { bank = wave.bank, address = wave.address, name = "WaveSamples" },
},
drumkits = { bank = drums.bank, address = drums.address, name = "Drumkits" },
source = "canonical Pokemon Gold ROM sound programs (Gen 2 driver)",
}
self:write("audio", data)
self:tick("Sound programs", #bankOrder + 2, #bankOrder + 2)
return data
end
-- Decode a Gen 2 text stream (macros/scripts/text.asm) into the port's
-- TextBox markers: \n line, \f page, \v scroll, {PLAYER}/{RIVAL}.
--
-- home/text.asm is TWO interleaved loops and the difference is load bearing:
--
-- DoTextUntilTerminator reads a COMMAND byte, and $50 there is TX_END -- the
-- whole stream stops.
-- TextCommand_START hands over to PlaceString, which prints CHARACTERS until
-- it meets $50 -- which is `@`, the end of that chunk and nothing more.
--
-- So the same byte ends a string or ends the text depending on which loop is
-- holding it, and `line "@"` really does sit inside the chunk `text "..."`
-- opened (a `text` macro emits no terminator of its own). Reading $50 as
-- "skip" everywhere works for a map string, which ends on `done` ($57), and
-- runs off the end of any string that ends on `text_end` -- which is every
-- string in data/text/*.asm, i.e. everything the phone and the decorations say.
--
-- `buffers`, when a caller passes one, collects the WRAM name behind each
-- TX_RAM in the order they appear. The decoded string writes every one of
-- them as the same `{STRBUF}` -- the port has one shared string buffer and
-- src/render/TextBox.lua resolves that single token -- but the cart has six,
-- and a line like the trade intro names two DIFFERENT ones. Recording which
-- is which alongside the text lets a caller fill them in order without
-- changing a marker every screen already reads.
function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
local out = {}
local i = 0
local hops = 0
-- false = DoTextUntilTerminator, true = inside PlaceString.
local inString = false
while i < 4096 do
local b = self.rom:byte(bank, address + i)
if b == 0x50 then
if not inString then break end -- TX_END
inString = false -- `@`: end of this chunk
elseif b == 0x57 or b == 0x58 then -- DONE / PROMPT, both PlaceString's
break
elseif b == 0x00 then
inString = true -- TX_START
elseif b == 0x16 and not inString then
-- TX_FAR: `db TX_FAR / dw addr / db bank` (macros/scripts/text.asm).
-- The stream CONTINUES at the far address rather than embedding it, and
-- everything after the pointer in this bank is the next string. No map
-- text uses one -- the 620 that do are engine strings, which is why this
-- only started mattering once the extractor reached banks $09 and $41.
local farAddr = self.rom:word(bank, address + i + 1)
local farBank = self.rom:byte(bank, address + i + 3)
hops = hops + 1
if hops > 8 or farBank == 0 or farBank > 0x7f
or farAddr < 0x4000 or farAddr >= 0x8000 then
break
end
-- The loop's own `i = i + 1` runs after this branch, so -1 lands on the
-- far stream's first byte.
bank, address, i = farBank, farAddr, -1
elseif b == 0x01 then
-- TX_RAM: dw wStringBuffer*; runtime fills via getmonname etc.
out[#out + 1] = "{STRBUF}"
if buffers then
local target = self.rom:word(bank, address + i + 1)
buffers[#buffers + 1] = TEXT_BUFFERS[target] or target
end
i = i + 2
elseif b == 0x4e or b == 0x4f then
out[#out + 1] = "\n"
elseif b == 0x51 then
out[#out + 1] = "\f"
elseif b == 0x55 then
out[#out + 1] = "\v"
elseif b == 0x52 then
out[#out + 1] = "{PLAYER}"
elseif b == 0x53 then
out[#out + 1] = "{RIVAL}"
elseif b == 0x54 then
out[#out + 1] = "POKé"
elseif b == 0x06 then
-- TX_PROMPT_BUTTON (e.g. empty _OakText3) : no glyphs.
elseif b == 0x09 and not inString then
-- TX_DECIMAL: `dw address / dn bytes, digits` (macros/scripts/text.asm).
-- PrintNum writes a runtime number here -- the price the Day-Care asks,
-- the quantity a mart clerk rings up -- so the marker stands in for it
-- and the call site fills it, the same way {STRBUF} stands in for a
-- TX_RAM. Four bytes: the byte AFTER the `dn` is the next command.
out[#out + 1] = "{NUM}"
i = i + 3
elseif b == 0x14 and not inString then
-- TX_STRINGBUFFER: `db buffer id`. Another name spliced at runtime, so
-- it decodes to the same marker TX_RAM does.
out[#out + 1] = "{STRBUF}"
i = i + 1
elseif b == 0x0c and not inString then
i = i + 1 -- TX_DOTS: `db count`, an animated ellipsis with no glyphs.
elseif not inString and TEXT_NO_GLYPH[b] then
-- TX_LOW / TX_SCROLL / TX_PAUSE / TX_WAIT_BUTTON / TX_DAY and the six
-- TX_SOUND_* jingles: box and timing commands that print nothing. Read
-- as characters they came out as whatever glyph the charmap had at that
-- byte -- `sound_caught_mon` inside _BreedEggHatchText decoded as a
-- kana -- so they are consumed here rather than printed.
else
local ch = charmap[tostring(b)]
if ch and not ch:match("^<") then
out[#out + 1] = ch
elseif ch == "<……>" or b == 0x56 then
out[#out + 1] = "……"
elseif not ch then
out[#out + 1] = ("{BYTE:%02X}"):format(b)
end
-- Skip other <$xx> control glyphs from the charmap.
end
i = i + 1
end
return table.concat(out)
end
local function wordFromArgs(args)
return (args[1] or 0) + (args[2] or 0) * 0x100
end
-- The `dba` operand every far-reaching opcode carries (farscall, farsjump,
-- farwritetext). macros/data.asm: `dba` is `dbw BANK(\1), \1`, so the BANK
-- comes FIRST and the address follows little-endian -- the opposite layout
-- from the neighbouring `dab`. Script_farsjump (engine/overworld/scripting.asm
-- 1190) reads it in that order too: `ld b, a` on the first byte, then l then h.
local function dbaFromArgs(args)
return (args[1] or 0), (args[2] or 0) + (args[3] or 0) * 0x100
end
local function romAddrOk(bank, address)
if type(bank) ~= "number" or type(address) ~= "number" then return false end
if bank < 0 or bank > 0x7f then return false end
if bank == 0 then return address >= 0 and address < 0x4000 end
return address >= 0x4000 and address < 0x8000
end
-- The side tables a script command NAMES rather than carries: the phone book,
-- the in-game trades, the elevator's floor labels, and the five decoration
-- descriptions. Each row's script pointers come back with the rest so
-- extractScriptsAndText can seed its queue from them -- which is the only way
-- into ROM bank $41, where every phone script lives and which no map points at.
--
-- Returns the table that is written as data/generated/events.lua; the caller
-- adds the disassembly.
function RomExtractorGen2:readEventTables()
local consts = self.manifest.constants
local charmap = self.manifest.charmap or {}
local out = {}
local function name(list, index, fallback)
if type(list) ~= "table" then return fallback end
return list[index + 1] or fallback
end
local function readName(bank, address, length)
local ok, str = pcall(self.rom.readString, self.rom,
bank, address, charmap, 0x50, length)
return ok and str or nil
end
-- data/phone/phone_contacts.asm. The struct (constants/phone_constants.asm
-- rsreset) is class, number, map group, map number, then two
-- {time, bank, addr} triples -- SCRIPT1 is the CALLEE half (you rang them),
-- SCRIPT2 the CALLER half (they rang you). Both are `dba`, so the bank byte
-- comes first and the address after it.
local contacts = self:symbol("PhoneContacts")
local phone = {}
for row = 0, #(consts.phoneContactOrder or {}) - 1 do
local base = contacts.address + row * PHONE_CONTACT_SIZE
local raw = self.rom:bytes(contacts.bank, base, PHONE_CONTACT_SIZE)
local group, mapNum = raw[3], raw[4]
phone[row] = {
id = row,
contact = name(consts.phoneContactOrder, row),
trainerClass = raw[1],
number = raw[2],
-- N_A is group $ff / map $ff, which no real map equals.
map = (group ~= 0xff) and self:mapNameByIds(group, mapNum) or nil,
calleeTime = raw[5],
callee = Opcodes.key(raw[6], raw[7] + raw[8] * 0x100),
calleeBank = raw[6], calleeAddress = raw[7] + raw[8] * 0x100,
callerTime = raw[9],
caller = Opcodes.key(raw[10], raw[11] + raw[12] * 0x100),
callerBank = raw[10], callerAddress = raw[11] + raw[12] * 0x100,
}
end
out.phone = phone
-- data/phone/special_calls.asm: `dw condition; db contact; dba script`.
-- The condition is a routine (SpecialCallOnlyWhenOutside /
-- SpecialCallWhereverYouAre), so its address is kept as a number for
-- Phone.lua to recognise rather than as anything runnable.
local special = self:symbol("SpecialPhoneCallList")
local specialCalls = {}
for row = 0, #(consts.specialCallOrder or {}) - 1 do
local base = special.address + row * SPECIALCALL_SIZE
local raw = self.rom:bytes(special.bank, base, SPECIALCALL_SIZE)
specialCalls[row + 1] = {
id = row + 1, -- SPECIALCALL_NONE is 0, so row 0 is SPECIALCALL_POKERUS
call = name(consts.specialCallOrder, row + 1),
condition = raw[1] + raw[2] * 0x100,
contact = raw[3],
script = Opcodes.key(raw[4], raw[5] + raw[6] * 0x100),
scriptBank = raw[4], scriptAddress = raw[5] + raw[6] * 0x100,
}
end
out.specialCalls = specialCalls
-- engine/phone/phone.asm's own three scripts, which no contact row and no
-- map points at: the wrong-number arm, the no-signal arm, and "they are on
-- this map, go talk to them". Named by the port's own labels rather than by
-- the symbol's, because WrongNumber.script is a local label.
out.phoneScripts = {}
for label, symbol in pairs({
WrongNumberScript = "WrongNumber.script",
PhoneOutOfAreaScript = "PhoneOutOfAreaScript",
PhoneScript_JustTalkToThem = "PhoneScript_JustTalkToThem",
}) do
local sym = self.symbols[symbol]
if sym then
out.phoneScripts[label] = {
script = Opcodes.key(sym[1], sym[2]),
scriptBank = sym[1], scriptAddress = sym[2],
}
end
end
-- data/events/npc_trades.asm, the `trade` command's own table.
local trades = self:symbol("NPCTrades")
-- NPCTRADE_ITEM is an item id byte (data/events/npc_trades.asm), and every
-- other item byte this file writes is named at extraction: wild held items
-- (:1329), trainer parties (:4120), givepokemail (:3178). Name it here too
-- so the cache carries the items.lua key the rest of the port indexes with.
local itemOrder = self.manifest.constants.itemOrder or {}
local tradeRows = {}
for row = 0, NUM_NPC_TRADES - 1 do
local base = trades.address + row * NPCTRADE_STRUCT_LENGTH
local raw = self.rom:bytes(trades.bank, base, 3)
local dvBase = base + 3 + MON_NAME_LENGTH
local tail = self.rom:bytes(trades.bank, dvBase, 5)
tradeRows[row + 1] = {
id = row,
dialog = name(consts.tradeDialogOrder, raw[1]),
-- The mon the player HANDS OVER is GIVEMON and the one they receive is
-- GETMON; the macro's own argument names are "requested, offered".
give = self:speciesName(raw[2]),
giveIndex = raw[2],
get = self:speciesName(raw[3]),
getIndex = raw[3],
nickname = readName(trades.bank, base + 3, MON_NAME_LENGTH),
-- NPCTRADE_DVS is a `dw` read as two raw DV bytes (attack/defense then
-- speed/special), not as a number.
dvs = { tail[1], tail[2] },
-- NO_ITEM (0) drops entirely; the numeric fallback keeps a manifest
-- built before itemOrder existed importable.
item = (tail[3] ~= 0) and (itemOrder[tail[3]] or tail[3]) or nil,
otId = tail[4] + tail[5] * 0x100,
otName = readName(trades.bank, dvBase + 5, NAME_LENGTH),
gender = name(consts.tradeGenderOrder,
self.rom:byte(trades.bank, dvBase + 5 + NAME_LENGTH)),
}
end
out.trades = tradeRows
-- The trade conversation's five lines, one set per TRADE_DIALOGSET_*.
-- PrintTradeText is `TradeTexts + 6 * dialog + 2 * dialogset`, so the table
-- is stored dialog-major: three INTROs, then three CANCELs, and so on.
local tradeTexts = self:symbol("TradeTexts")
local dialogs = { "TRADE_DIALOG_INTRO", "TRADE_DIALOG_CANCEL",
"TRADE_DIALOG_WRONG", "TRADE_DIALOG_COMPLETE", "TRADE_DIALOG_AFTER" }
local sets = consts.tradeDialogOrder or {}
out.tradeTexts, out.tradeBuffers = {}, {}
-- The two markers a trade line splices are DIFFERENT buffers -- the intro is
-- wStringBuffer1 ("do you have X?") then wStringBuffer2 ("for my Y?") --
-- and both decode to {STRBUF}, so the order has to be recorded next to the
-- text or the two mons come out swapped.
for d, dialog in ipairs(dialogs) do
local row, bufRow = {}, {}
for s = 1, #sets do
local addr = self.rom:word(tradeTexts.bank,
tradeTexts.address + (d - 1) * 6 + (s - 1) * 2)
local buffers = {}
row[sets[s]] =
self:decodeGen2Text(tradeTexts.bank, addr, charmap, buffers)
bufRow[sets[s]] = buffers
end
out.tradeTexts[dialog] = row
out.tradeBuffers[dialog] = bufRow
end
-- The two lines NPCTrade prints around the swap, then the six the trade
-- ANIMATION prints (engine/movie/trade_animation.asm, text in
-- data/text/common_1.asm). They all land in the same table because they are
-- all named rather than indexed; the empty _MonNameSentToText is skipped,
-- since an open box with nothing in it needs no string.
for _, label in ipairs({ "NPCTradeCableText", "TradedForText",
"_MonWasSentToText", "_ForYourMonSendsText", "_OTSendsText",
"_BidsFarewellToMonText", "_MonNameBidsFarewellText",
"_TakeGoodCareOfMonText" }) do
local sym = self.symbols[label]
if sym then
local buffers = {}
out.tradeTexts[label] =
self:decodeGen2Text(sym[1], sym[2], charmap, buffers)
out.tradeBuffers[label] = buffers
end
end
-- data/events/bug_contest_flags.asm: `table_width 2`, one
-- EVENT_BUG_CATCHING_CONTESTANT_*A word per contestant. These are wEventFlags
-- NUMBERS, not names, which is what SelectRandomBugContestContestants
-- (engine/events/bug_contest/contest_2.asm) hands to EventFlagAction -- it
-- resets all ten and then sets five, and a SET flag is what keeps that
-- object off NationalParkBugContest. A missing symbol is skipped rather
-- than fatal so an older manifest still imports and BugContest.FLAGS carries
-- the map.
local contestFlags = self.symbols.BugCatchingContestantEventFlagTable
if contestFlags then
local flagTable = self:symbol("BugCatchingContestantEventFlagTable")
local flags = {}
for row = 0, NUM_BUG_CONTESTANTS - 1 do
flags[row + 1] =
self.rom:word(flagTable.bank, flagTable.address + row * 2)
end
out.bugContestFlags = flags
end
-- data/events/elevator_floors.asm: a `dw` per FLOOR_* at a "B1F@" string.
local floors = self:symbol("ElevatorFloorNames")
local floorNames = {}
for row = 0, #(consts.floorOrder or {}) - 1 do
local addr = self.rom:word(floors.bank, floors.address + row * 2)
floorNames[row + 1] = readName(floors.bank, addr, 8)
end
out.floorNames = floorNames
-- describedecoration's five arms (engine/overworld/decorations.asm). Each
-- arm is ASM that hands back a script, so what is emitted is the SCRIPT each
-- can pick: the poster table plus its "nothing installed" miss, the one
-- script the two ornaments and the console share, and the giant ornament's.
local function scriptRef(bank, address)
return { script = Opcodes.key(bank, address),
scriptBank = bank, scriptAddress = address }
end
local posterTable = self:symbol("DecorationDesc_PosterPointers")
local posters = {}
for i = 0, 15 do
local row = posterTable.address + i * 3
local deco = self.rom:byte(posterTable.bank, row)
if deco == 0xff then break end
local ref = scriptRef(posterTable.bank,
self.rom:word(posterTable.bank, row + 1))
ref.decoration = deco
posters[#posters + 1] = ref
end
local nullPoster = self:symbol("DecorationDesc_NullPoster")
local ornament = self:symbol(
"DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript")
local bigDoll = self:symbol("DecorationDesc_GiantOrnament.BigDollScript")
local poster = scriptRef(nullPoster.bank, nullPoster.address)
poster.posters = posters
out.decorations = {
DECODESC_POSTER = poster,
DECODESC_LEFT_DOLL = scriptRef(ornament.bank, ornament.address),
DECODESC_RIGHT_DOLL = scriptRef(ornament.bank, ornament.address),
DECODESC_BIG_DOLL = scriptRef(bigDoll.bank, bigDoll.address),
DECODESC_CONSOLE = scriptRef(ornament.bank, ornament.address),
}
out.decorationOrder = consts.decoDescOrder
return out
end
-- Text NO script pointer reaches, seeded by name.
--
-- The walker below only decodes a string some `writetext` / `farwritetext` /
-- `trainer` struct named, which is every line an NPC says and nothing else.
-- A line an ENGINE routine prints -- `ld hl, .SomeText / call PrintText` --
-- has no pointer in any bytecode, so three whole blocks used to arrive only
-- as hand transcriptions at their call sites:
--
-- the Day-Care and breeding block of data/text/common_1.asm and
-- common_2.asm, printed by engine/events/daycare.asm and
-- engine/pokemon/breeding.asm (src/ui/gen2/DayCareMenu.lua),
-- the POKeMART clerk's whole conversation in data/text/common_2.asm,
-- printed by engine/items/mart.asm (src/ui/gen2/MartMenu.lua),
-- and the Hall of Fame's three flavour strings, which are plain `db "…@"`
-- inside engine/events/halloffame.asm rather than text streams at all
-- (src/ui/gen2/HallOfFame.lua).
--
-- Each label below is the FAR string (`_Foo`), not the near `text_far` stub
-- that names it: both decode to the same characters, and the far one is the
-- symbol the whole block shares a bank with. They are written out as
-- text.labels[label] -> the "bank:addr" key the string landed on, so a call
-- site asks for a pokegold label and never for an address.
local NAMED_TEXT = {
-- engine/events/daycare.asm, in its own text-table order.
"_DaycareDummyText",
"_DayCareManIntroText", "_DayCareManIntroEggText",
"_DayCareLadyIntroText", "_DayCareLadyIntroEggText",
"_WhatShouldIRaiseText", "_OnlyOneMonText", "_CantAcceptEggText",
"_RemoveMailText", "_LastHealthyMonText", "_IllRaiseYourMonText",
"_ComeBackLaterText", "_AreWeGeniusesText", "_YourMonHasGrownText",
"_PerfectHeresYourMonText", "_GotBackMonText", "_BackAlreadyText",
"_HaveNoRoomText", "_NotEnoughMoneyText", "_OhFineThenText",
"_ComeAgainText", "_NotYetText", "_FoundAnEggText", "_ReceivedEggText",
"_TakeGoodCareOfEggText", "_IllKeepItThanksText", "_NoRoomForEggText",
-- engine/pokemon/breeding.asm: the hatch, the "you left X here" lines the
-- DayCareMon specials print, and the five compatibility verdicts.
"Text_BreedHuh", "_BreedClearboxText", "_BreedEggHatchText",
"_BreedAskNicknameText",
"_LeftWithDayCareManText", "_LeftWithDayCareLadyText",
"_BreedBrimmingWithEnergyText", "_BreedNoInterestText",
"_BreedAppearsToCareForText", "_BreedFriendlyText",
"_BreedShowsInterestText",
-- engine/items/mart.asm: the four MARTTYPE_* dialogs and the sell flow.
"_MartWelcomeText", "_MartAskMoreText", "_MartComeAgainText",
"_MartHowManyText", "_MartFinalPriceText", "_MartThanksText",
"_MartNoMoneyText", "_MartPackFullText",
"_HerbShopLadyIntroText", "_HerbalLadyHowManyText",
"_HerbalLadyFinalPriceText", "_HerbalLadyThanksText",
"_HerbalLadyPackFullText", "_HerbalLadyNoMoneyText",
"_HerbalLadyComeAgainText",
"_BargainShopIntroText", "_BargainShopFinalPriceText",
"_BargainShopThanksText", "_BargainShopPackFullText",
"_BargainShopSoldOutText", "_BargainShopNoFundsText",
"_BargainShopComeAgainText",
"_PharmacyIntroText", "_PharmacyHowManyText", "_PharmacyFinalPriceText",
"_PharmacyThanksText", "_PharmacyPackFullText", "_PharmacyNoMoneyText",
"_PharmacyComeAgainText",
"_NothingToSellText", "_MartSellHowManyText", "_MartSellPriceText",
"_MartCantBuyText", "_MartBoughtText",
-- engine/events/halloffame.asm. These three are `db` strings ending on the
-- same "@" a text chunk ends on, so the text walker reads them unchanged --
-- and their leading spaces are load bearing, because PrintNum writes the
-- win count over the first two columns of " -Time Famer".
"AnimateHallOfFame.String_NewHallOfFamer",
"_HallOfFamePC.TimeFamer", "_HallOfFamePC.HOFMaster",
-- data/text/common_2.asm's MAIL block, printed by engine/pokemon/mail.asm's
-- MailboxPC and engine/pokemon/mon_menu.asm's MonMailAction
-- (src/ui/gen2/MailboxMenu.lua and src/ui/gen2/MailMenu.lua). Same shape as
-- the Day-Care block above: asm prints these, so no bytecode points at them
-- and the walker has to be seeded by name.
"_EmptyMailboxText", "_MailClearedPutAwayText", "_MailPackFullText",
"_MailMessageLostText", "_MailAlreadyHoldingItemText", "_MailEggText",
"_MailMovedFromBoxText", "_MailLoseMessageText", "_MailDetachedText",
"_MailNoSpaceText", "_MailAskSendToPCText", "_MailboxFullText",
"_MailSentToPCText", "_PCMonHoldingMailText", "_PokemonRemoveMailText",
}
-- Walk every map's object/bg/coord/scene script pointers, disassemble
-- bytecode into command lists, rip writetext strings, and capture
-- applymovement byte streams. Runtime never needs the ROM -- just
-- data/generated/scripts.lua + text.lua.
function RomExtractorGen2:extractScriptsAndText(maps, stdScripts)
self:beginStage("Scripts & text")
local charmap = self.manifest.charmap or {}
local scripts, text = { generation = 2 }, { generation = 2 }
local movements = {}
local queue = {}
local queued = {}
local function enqueue(bank, address)
if not romAddrOk(bank, address) or address == 0 then return end
-- Scripts live in banked ROM, not ROM0.
if bank == 0 then return end
local key = Opcodes.key(bank, address)
if queued[key] then return end
queued[key] = true
queue[#queue + 1] = { bank = bank, address = address, key = key }
end
-- home/map.asm ObjectEvent ("jumptextfaceplayer ObjectEventText") is the
-- line an object with no script of its own says, and 44 object_events across
-- the maps point at it with the same `dw`. It lives in ROM0, which is
-- visible from every bank, so its pointer is BELOW $4000 -- exactly what
-- enqueue rejects, because a real map script pointer is always banked. The
-- shared body is walked once at bank 0 and every object that names it points
-- at that one key, rather than at a key in its own map's script bank where
-- nothing was ever disassembled.
--
-- Returns the key so a caller can use it as its scriptKey, or nil when the
-- address is not a ROM0 one after all.
local function enqueueHome(address)
if not romAddrOk(0, address) or address == 0 then return nil end
local key = Opcodes.key(0, address)
if not queued[key] then
queued[key] = true
queue[#queue + 1] = { bank = 0, address = address, key = key }
end
return key
end
local function ensureText(bank, address)
if not romAddrOk(bank, address) then return nil end
local key = Opcodes.key(bank, address)
if not text[key] then
local ok, decoded = pcall(self.decodeGen2Text, self, bank, address, charmap)
text[key] = ok and decoded or ""
end
return key
end
local function ensureMovement(bank, address)
if not romAddrOk(bank, address) or address == 0 then return nil end
local key = Opcodes.key(bank, address)
if movements[key] then return key end
local bytes = {}
for i = 0, 64 do
local ok, b = pcall(self.rom.byte, self.rom, bank, address + i)
if not ok then break end
bytes[#bytes + 1] = b
if b == 0x47 or b == 0x48 then break end -- step_end / step_wait_end
end
movements[key] = bytes
return key
end
-- A MAIL message: raw charmap bytes terminated by '@', NOT a text stream.
-- Both mail opcodes point at one (Script_givepokemail FarCopyBytes's
-- MAIL_MSG_LENGTH of them into wMonMailMessageBuffer; Script_checkpokemail
-- hands its pointer to CheckPokeMail, which compares byte for byte until the
-- '@'), so neither can go through decodeGen2Text -- that walks TX_* commands
-- and would treat a $16 in the middle of somebody's sentence as a far jump.
--
-- The one control byte that IS meaningful here is `next` ($4e), the line
-- break the message carries across the struct's two MAIL_LINE_LENGTH rows;
-- it decodes to "\n" so the stored letter and the expected one compare equal
-- (src/core/gen2/Mail.lua).
local function readMailMessage(bank, address, limit)
if not romAddrOk(bank, address) then return nil end
local out = {}
for i = 0, (limit or 0x20) - 1 do
local ok, b = pcall(self.rom.byte, self.rom, bank, address + i)
if not ok then break end
if b == 0x50 then break end
if b == 0x4e or b == 0x4f then
out[#out + 1] = "\n"
else
local ch = charmap[tostring(b)]
if ch and not ch:match("^<") then
out[#out + 1] = ch
elseif not ch then
out[#out + 1] = ("{BYTE:%02X}"):format(b)
end
end
end
return table.concat(out)
end
-- MenuHeader (home/menu.asm LoadMenuHeader, ram/wram.asm wMenuHeader):
-- db flags; menu_coords x1, y1, x2, y2; dw data; db default cursor
-- `menu_coords` lays each corner down Y FIRST (macros/coords.asm is
-- `db \2, \1` twice), so the four bytes are top, left, bottom, right.
--
-- The data block behind it is read two ways depending on which command
-- consumes it. A vertical menu is `db flags; db items;` then one `@`-ended
-- string per item. A 2D menu is `db flags; dn rows, cols; db spacing;
-- dba strings; dbw bank, function` -- so its strings sit behind ANOTHER far
-- pointer, and rows * cols is how many of them to read.
--
-- Both shapes are emitted; the command that follows loadmenu picks. Reading
-- both is safe because neither walks past the item count it was given.
local function readMenuStrings(bank, address, count, limit)
local items, cursor = {}, address
for _ = 1, count do
if not romAddrOk(bank, cursor) then break end
local ok, str = pcall(self.rom.readString, self.rom,
bank, cursor, charmap, 0x50, limit or 24)
if not ok then break end
items[#items + 1] = str
-- readString stops AT the terminator and does not report how far it got,
-- so the stride is measured here.
local len = 0
while len < (limit or 24) do
local okB, b = pcall(self.rom.byte, self.rom, bank, cursor + len)
if not okB or b == 0x50 then break end
len = len + 1
end
cursor = cursor + len + 1
end
return items
end
local function readMenuHeader(bank, address)
if not romAddrOk(bank, address) then return nil end
local okRaw, raw = pcall(self.rom.bytes, self.rom,
bank, address, MENU_HEADER_LENGTH)
if not okRaw then return nil end
local dataAddr = raw[6] + raw[7] * 0x100
local header = {
flags = raw[1],
top = raw[2], left = raw[3], bottom = raw[4], right = raw[5],
cursor = raw[8],
key = Opcodes.key(bank, address),
}
if not romAddrOk(bank, dataAddr) then return header end
local okData, data = pcall(self.rom.bytes, self.rom, bank, dataAddr, 8)
if not okData then return header end
header.dataFlags = data[1]
-- Vertical: db items, then the strings inline.
local count = data[2]
if count > 0 and count <= 16 then
header.items = readMenuStrings(bank, dataAddr + 2, count)
end
-- 2D: dn rows, cols (one byte) / db spacing / dba strings.
local rows, cols = math.floor(data[2] / 16), data[2] % 16
if rows > 0 and cols > 0 and rows * cols <= 32 then
local strBank = data[4]
local strAddr = data[5] + data[6] * 0x100
if romAddrOk(strBank, strAddr) then
header.grid = { rows = rows, cols = cols, spacing = data[3] }
header.gridItems = readMenuStrings(strBank, strAddr, rows * cols)
end
end
return header
end
-- `writecmdqueue` names a cmdqueue entry (macros/scripts/maps.asm):
-- dbw type, addr; dw filler
-- and the only type any map uses is CMDQUEUE_STONETABLE, whose address is a
-- `stonetable warp, object, script` list ending on `db -1`. Following it is
-- what puts the per-boulder scripts in scripts.lua: nothing else points at
-- them, so without this the Ice Path and Blackthorn Gym tables have to be
-- hand-ported.
local function readCmdQueueEntry(bank, address)
if not romAddrOk(bank, address) then return nil end
local okRaw, raw = pcall(self.rom.bytes, self.rom,
bank, address, CMDQUEUE_ENTRY_SIZE)
if not okRaw then return nil end
local kind = raw[1]
local target = raw[2] + raw[3] * 0x100
local entry = {
type = kind,
queue = orderName(self.manifest.constants.cmdQueueOrder, kind + 1),
address = target,
}
if kind ~= CMDQUEUE_STONETABLE or not romAddrOk(bank, target) then
return entry
end
local rows = {}
for i = 0, 15 do
local row = target + i * STONETABLE_LENGTH
local okWarp, warp = pcall(self.rom.byte, self.rom, bank, row)
if not okWarp or warp == 0xff then break end
local okScript, script = pcall(self.rom.word, self.rom, bank, row + 2)
if not okScript then break end
rows[#rows + 1] = {
warp = warp,
object = self.rom:byte(bank, row + 1),
scriptKey = Opcodes.key(bank, script),
}
enqueue(bank, script)
end
entry.rows = rows
return entry
end
-- `elevator` names a floor list: db count, then `elevfloor floor, warp, map`
-- (db floor, warp; map_id) rows. Elevator itself performs the ride, so the
-- warp number and destination map are the whole of what the port needs.
local function readElevator(bank, address)
if not romAddrOk(bank, address) then return nil end
local okCount, count = pcall(self.rom.byte, self.rom, bank, address)
if not okCount or count == 0 or count > 16 then return nil end
local floors = {}
for i = 0, count - 1 do
local row = address + 1 + i * 4
local okRaw, raw = pcall(self.rom.bytes, self.rom, bank, row, 4)
if not okRaw then break end
floors[#floors + 1] = {
floor = orderName(self.manifest.constants.floorOrder, raw[1] + 1),
floorId = raw[1],
destWarp = raw[2],
destGroup = raw[3], destMapNum = raw[4],
destMap = self:mapNameByIds(raw[3], raw[4]),
}
end
return floors
end
-- `trainer` struct (macros/scripts/maps.asm):
-- dw beat-event flag; db class, member; dw seen, win, loss; dw after-script
-- The after-script is the only part that is bytecode, so it is the pointer
-- the object's scriptKey ends up naming.
local function readTrainerHeader(bank, address)
if not romAddrOk(bank, address) then return nil end
local ok, raw = pcall(self.rom.bytes, self.rom, bank, address, 12)
if not ok then return nil end
local function word(i) return raw[i] + raw[i + 1] * 0x100 end
local afterAddr = word(11)
local entry = {
event = word(1),
class = raw[3],
member = raw[4],
seenText = ensureText(bank, word(5)),
winText = ensureText(bank, word(7)),
lossText = ensureText(bank, word(9)),
}
if entry.event == 0xFFFF then entry.event = nil end
if afterAddr ~= 0 and romAddrOk(bank, afterAddr) then
entry.scriptKey = Opcodes.key(bank, afterAddr)
enqueue(bank, afterAddr)
end
return entry
end
-- `itemball item, quantity` -- two bytes, no script.
local function readItemBall(bank, address)
if not romAddrOk(bank, address) then return nil end
local ok, raw = pcall(self.rom.bytes, self.rom, bank, address, 2)
if not ok then return nil end
return { item = raw[1], quantity = raw[2] }
end
-- `hiddenitem item, flag` is `dwb \2, \1` (macros/scripts/maps.asm): the
-- EVENT_* flag first as a word, the item id after it as a byte. Three bytes,
-- no script -- CheckForHiddenItems (engine/events/checkforhiddenitems.asm)
-- reads the word straight into EventFlagAction and HiddenItemScript
-- (engine/events/hidden_item.asm) reads the byte through wHiddenItemID.
--
-- The word comes first and the byte last, which is the opposite of the way
-- `itemball` above lays its two bytes down; `dwb` is where that order lives,
-- not the macro's argument list.
local function readHiddenItem(bank, address)
if not romAddrOk(bank, address) then return nil end
local ok, raw = pcall(self.rom.bytes, self.rom, bank, address, 3)
if not ok then return nil end
return { event = raw[1] + raw[2] * 0x100, item = raw[3] }
end
-- Seed the std scripts first: they are reachable only through callstd /
-- jumpstd ids, never through a map pointer, so nothing else would queue
-- them and every `callstd` would dead-end at a missing key.
for _, entry in pairs(stdScripts and stdScripts.scripts or {}) do
enqueue(entry.bank, entry.address)
end
-- Seed the side tables next, for the same reason: the phone's callee/caller
-- scripts all live in ROM bank $41 and no map points into it, so without
-- these three seeds the bank is unreachable and every phone call arrives
-- with a descriptor and no body. The decoration scripts are the same shape
-- one bank over.
local events = self:readEventTables()
for _, row in pairs(events.phone or {}) do
enqueue(row.calleeBank, row.calleeAddress)
enqueue(row.callerBank, row.callerAddress)
end
for _, row in ipairs(events.specialCalls or {}) do
enqueue(row.scriptBank, row.scriptAddress)
end
for _, row in pairs(events.phoneScripts or {}) do
enqueue(row.scriptBank, row.scriptAddress)
end
for _, arm in pairs(events.decorations or {}) do
enqueue(arm.scriptBank, arm.scriptAddress)
for _, poster in ipairs(arm.posters or {}) do
enqueue(poster.scriptBank, poster.scriptAddress)
end
end
-- And seed home/map.asm ObjectEvent, for a third flavour of the same reason:
-- it is the shared generic line, it lives in an engine bank the map walk
-- never enters, and the maps that name it all name it by an address enqueue
-- would throw away. Seeding it from the symbol keeps the body present even
-- if the maps ever stop pointing at it.
local objectEvent = self.symbols.ObjectEvent
if objectEvent and objectEvent[1] == 0 then
enqueueHome(objectEvent[2])
end
-- And seed the NAMED_TEXT blocks, which are the same problem one step
-- further out: no script points at them because no SCRIPT prints them. A
-- missing symbol is skipped rather than fatal, so an older manifest still
-- imports and the call sites fall back to their transcriptions.
local labels = {}
for _, label in ipairs(NAMED_TEXT) do
local sym = self.symbols[label]
if sym then
local key = ensureText(sym[1], sym[2])
if key then labels[label] = key end
end
end
text.labels = labels
-- Seed from every map event that carries a script pointer.
local mapCount = 0
for mapId, def in pairs(maps) do
if type(def) == "table" and def.scripts and def.scripts.bank then
mapCount = mapCount + 1
local bank = def.scripts.bank
for _, obj in ipairs(def.objects or {}) do
-- object_event's function byte decides what its pointer *is*. Only
-- OBJECTTYPE_SCRIPT aims at bytecode; OBJECTTYPE_TRAINER aims at the
-- `trainer` struct (macros/scripts/maps.asm) and OBJECTTYPE_ITEMBALL
-- at two raw bytes. Disassembling those as commands yields noise.
if obj.type == OBJECTTYPE_TRAINER and obj.script then
obj.trainer = readTrainerHeader(bank, obj.script)
if obj.trainer and obj.trainer.scriptKey then
obj.scriptKey = obj.trainer.scriptKey
end
elseif obj.type == OBJECTTYPE_ITEMBALL and obj.script then
obj.itemball = readItemBall(bank, obj.script)
elseif obj.script then
-- A pointer below $4000 is ROM0, i.e. the shared ObjectEvent line,
-- and belongs to bank 0 rather than to this map's script bank.
obj.scriptKey = enqueueHome(obj.script)
if not obj.scriptKey then
obj.scriptKey = Opcodes.key(bank, obj.script)
enqueue(bank, obj.script)
end
end
end
for _, ev in ipairs(def.bgEvents or {}) do
-- A bg_event's function byte decides what its pointer *is*, exactly as
-- an object_event's does above. BGEVENT_ITEM aims at `hiddenitem`
-- data, so it is read rather than walked: queueing it disassembled the
-- flag word and the item byte as opcodes and, worse, followed whatever
-- jump the noise happened to spell into more noise.
if ev.kind == BGEVENT_ITEM and ev.script then
ev.hiddenItem = readHiddenItem(bank, ev.script)
elseif ev.script then
-- IFSET/IFNOTSET already resolved their `conditional_event` above;
-- the script it names is in the map's own bank.
local scriptBank = bank
local key = Opcodes.key(scriptBank, ev.script)
ev.scriptKey = key
enqueue(scriptBank, ev.script)
end
end
for _, ev in ipairs(def.coordEvents or {}) do
if ev.script then
local key = Opcodes.key(bank, ev.script)
ev.scriptKey = key
enqueue(bank, ev.script)
end
end
for _, sc in pairs(def.sceneScripts or {}) do
if type(sc) == "table" and sc.script then
enqueue(bank, sc.script)
end
end
for _, cb in ipairs(def.callbacks or {}) do
enqueue(bank, cb.script)
end
end
end
local qi, disassembled = 1, 0
while queue[qi] do
local item = queue[qi]
qi = qi + 1
local bank, pc = item.bank, item.address
local commands = {}
for _ = 1, 256 do
if not romAddrOk(bank, pc) then
commands[#commands + 1] = { op = "truncated", reason = "pc" }
break
end
local okByte, opcode = pcall(self.rom.byte, self.rom, bank, pc)
if not okByte then
commands[#commands + 1] = { op = "truncated", reason = "read" }
break
end
local info = Opcodes[opcode]
if not info then
commands[#commands + 1] = {
op = "unknown", code = opcode,
source = ("ROM:%s"):format(item.key),
}
break
end
-- givepoke is variable-length: 4 bytes, or 8 when the trainer flag is set.
local size = info.size
if info.name == "givepoke" then
local okTr, trainer = pcall(self.rom.byte, self.rom, bank, pc + 4)
size = (okTr and trainer ~= 0) and 8 or 4
end
if not romAddrOk(bank, pc + size) then
commands[#commands + 1] = { op = "truncated", reason = "args" }
break
end
local okArgs, args = pcall(self.rom.bytes, self.rom, bank, pc + 1, size)
if not okArgs then
commands[#commands + 1] = { op = "truncated", reason = "args" }
break
end
local cmd = { op = info.name }
local nextPc = pc + 1 + size
if info.name == "writetext" or info.name == "jumptext"
or info.name == "jumptextfaceplayer" then
cmd.text = ensureText(bank, wordFromArgs(args))
elseif info.name == "farwritetext" then
local tBank, tAddr = dbaFromArgs(args)
cmd.text = ensureText(tBank, tAddr)
elseif info.name == "checkevent" or info.name == "setevent"
or info.name == "clearevent" then
cmd.event = wordFromArgs(args)
elseif info.name == "checkflag" or info.name == "setflag"
or info.name == "clearflag" then
cmd.flag = wordFromArgs(args)
elseif info.name == "iftrue" or info.name == "iffalse"
or info.name == "sjump" or info.name == "scall"
or info.name == "stopandsjump" or info.name == "sdefer" then
local target = wordFromArgs(args)
cmd.script = Opcodes.key(bank, target)
enqueue(bank, target)
elseif info.name == "ifequal" or info.name == "ifnotequal"
or info.name == "ifgreater" or info.name == "ifless" then
cmd.value = args[1]
local target = args[2] + args[3] * 0x100
cmd.script = Opcodes.key(bank, target)
enqueue(bank, target)
elseif info.name == "farscall" or info.name == "farsjump" then
local tBank, tAddr = dbaFromArgs(args)
cmd.script = Opcodes.key(tBank, tAddr)
enqueue(tBank, tAddr)
elseif info.name == "jumpstd" or info.name == "callstd" then
cmd.id = wordFromArgs(args)
-- Resolve now so the VM can jump straight to a scripts.lua key
-- instead of carrying a std-script table of its own.
local label = stdScripts and stdScripts.byId
and stdScripts.byId[cmd.id]
local entry = label and stdScripts.scripts[label]
if entry then
cmd.std = label
cmd.script = entry.key
end
elseif info.name == "special" or info.name == "playmusic"
or info.name == "playsound" or info.name == "cry" then
cmd.id = wordFromArgs(args)
elseif info.name == "pause" then
cmd.frames = args[1]
elseif info.name == "setscene" then
cmd.scene = args[1]
elseif info.name == "setmapscene" then
cmd.group, cmd.map, cmd.scene = args[1], args[2], args[3]
elseif info.name == "turnobject" then
cmd.object = args[1]
cmd.facing = args[2]
elseif info.name == "applymovement" then
cmd.object = args[1]
local movAddr = wordFromArgs({ args[2], args[3] })
cmd.movement = ensureMovement(bank, movAddr)
elseif info.name == "applymovementlasttalked" then
local movAddr = wordFromArgs(args)
cmd.movement = ensureMovement(bank, movAddr)
elseif info.name == "givepoke" then
cmd.species, cmd.level, cmd.item, cmd.trainer =
args[1], args[2], args[3], args[4]
elseif info.name == "pokepic" or info.name == "disappear" then
cmd.species = args[1] -- pokepic
cmd.object = args[1] -- disappear (same byte)
cmd.args = args
elseif info.name == "getmonname" then
cmd.species, cmd.buffer = args[1], args[2]
elseif info.name == "getitemname" then
cmd.item, cmd.buffer = args[1], args[2]
elseif info.name == "getstring" then
-- `getstring buffer, pointer` lays the pointer down first (dw) and the
-- buffer id last -- see macros/scripts/events.asm. The target is a
-- plain `@`-terminated name, not a text stream, so read it as a string:
-- Script_getstring CopyName1's it into wStringBuffer2, which is what
-- the following writetext's TX_RAM ({STRBUF}) prints.
cmd.buffer = args[3]
local sAddr = wordFromArgs(args)
if romAddrOk(bank, sAddr) then
local okStr, str = pcall(self.rom.readString, self.rom,
bank, sAddr, charmap, 0x50, 32)
cmd.string = okStr and str or nil
end
elseif info.name == "givepokemail" then
-- `givepokemail pointer` (Script_givepokemail): the target is `db item`
-- followed by MAIL_MSG_LENGTH message bytes, in the SCRIPT'S own bank
-- (`ld a, [wScriptBank] / call GetFarByte`). One call site in the
-- game, RandyScript's GiftSpearowMail in
-- maps/Route35GoldenrodGate.asm. `args` is kept alongside so a reader
-- that only knows the raw word still has it.
cmd.args = args
local mAddr = wordFromArgs(args)
if romAddrOk(bank, mAddr) then
local okItem, itemByte = pcall(self.rom.byte, self.rom, bank, mAddr)
local message = readMailMessage(bank, mAddr + 1, 0x20)
if okItem and message then
cmd.mail = {
item = (self.manifest.constants.itemOrder or {})[itemByte],
message = message,
}
end
end
elseif info.name == "checkpokemail" then
-- `checkpokemail pointer`: the target is the EXPECTED message alone --
-- no item byte -- and CheckPokeMail walks it until the '@', so a
-- message stored longer than this one still matches.
cmd.args = args
local mAddr = wordFromArgs(args)
local message = readMailMessage(bank, mAddr, 0x20)
if message then cmd.mail = { message = message } end
elseif info.name == "gettrainername" then
cmd.group, cmd.trainer, cmd.buffer = args[1], args[2], args[3]
elseif info.name == "loadtrainer" then
cmd.class, cmd.member = args[1], args[2]
elseif info.name == "loadwildmon" then
cmd.species, cmd.level = args[1], args[2]
elseif info.name == "winlosstext" then
-- Overrides the `trainer` struct's win/loss text for one battle.
cmd.winText = ensureText(bank, wordFromArgs(args))
cmd.lossText = ensureText(bank, args[3] + args[4] * 0x100)
elseif info.name == "trainertext" then
cmd.index = args[1]
elseif info.name == "trainerflagaction" then
cmd.action = args[1]
elseif info.name == "setlasttalked" then
cmd.object = args[1]
elseif info.name == "showemote" then
cmd.emote, cmd.object, cmd.frames = args[1], args[2], args[3]
elseif info.name == "giveitem" or info.name == "verbosegiveitem" then
cmd.item, cmd.quantity = args[1], args[2]
elseif info.name == "addcellnum" or info.name == "delcellnum"
or info.name == "checkcellnum" then
cmd.phone = args[1]
elseif info.name == "readvar" or info.name == "writevar" then
cmd.var = args[1]
elseif info.name == "follow" or info.name == "faceobject"
or info.name == "follownotexact" then
cmd.a, cmd.b = args[1], args[2]
elseif info.name == "loadmenu" then
-- The header sits in the SCRIPT'S own bank (Script_loadmenu calls
-- LoadMenuHeader through Call_a_de with wScriptBank in a).
cmd.menu = readMenuHeader(bank, wordFromArgs(args))
elseif info.name == "writecmdqueue" then
cmd.queue = readCmdQueueEntry(bank, wordFromArgs(args))
elseif info.name == "elevator" then
cmd.floors = readElevator(bank, wordFromArgs(args))
elseif info.name == "trade" then
cmd.trade = args[1]
elseif info.name == "describedecoration" then
cmd.decoration = args[1]
cmd.decorationName = orderName(
self.manifest.constants.decoDescOrder, args[1] + 1)
elseif size > 0 then
cmd.args = args
end
commands[#commands + 1] = cmd
if Opcodes.TERMINATORS[info.name] then break end
pc = nextPc
end
scripts[item.key] = commands
disassembled = disassembled + 1
if disassembled % 64 == 0 then
self:tick("Scripts & text", disassembled, math.max(disassembled, #queue))
end
end
scripts.movements = movements
-- Rewrite maps.lua with scriptKey fields now that objects were annotated.
self:write("maps", maps)
self:write("scripts", scripts)
self:write("text", text)
self:write("events", events)
self:extractInitialEvents()
self:tick("Scripts & text", 1, 1)
return { scripts = scripts, text = text, events = events,
mapCount = mapCount, scriptCount = disassembled }
end
-- New-game seed: walk InitializeEventsScript and collect what it sets.
--
-- Retail Gold's numeric EVENT_* values differ from pret's current const_def
-- order, so we must take them from the cart, not hardcode.
--
-- It is not only `setevent`. The same script ends with nine `variablesprite`
-- assignments, and they are not decoration: SPRITE_WEIRD_TREE ($f4) is a
-- wVariableSprites SLOT, not a sheet, so until something fills it nothing
-- spawns at all. Dropping them meant the Sudowoodo on Route 36 was simply not
-- there -- and with it TM08 ROCK SMASH, the Burned Tower, Morty, FOGBADGE and
-- SURF -- along with the Olivine rival, the Azalea Rocket, the four Fuchsia
-- gym Janines, the Copycat and the Janine impersonator.
function RomExtractorGen2:extractInitialEvents()
local sym = self.symbols.InitializeEventsScript
if not sym then
self:write("initial_events", {
generation = 2,
source = "missing symbol InitializeEventsScript",
flags = {}, engineFlags = {}, sprites = {},
})
return
end
local bank, pc = sym[1], sym[2]
local flags, seen = {}, {}
local engineFlags, engineSeen = {}, {}
local sprites = {}
for _ = 1, 512 do
if not romAddrOk(bank, pc) then break end
local okByte, opcode = pcall(self.rom.byte, self.rom, bank, pc)
if not okByte then break end
local info = Opcodes[opcode]
if not info then break end
local size = info.size
local okArgs, args = true, {}
if size > 0 then
okArgs, args = pcall(self.rom.bytes, self.rom, bank, pc + 1, size)
if not okArgs then break end
end
if info.name == "setevent" then
local id = wordFromArgs(args)
if not seen[id] then
seen[id] = true
flags[#flags + 1] = id
end
elseif info.name == "setflag" then
local id = wordFromArgs(args)
if not engineSeen[id] then
engineSeen[id] = true
engineFlags[#engineFlags + 1] = id
end
elseif info.name == "variablesprite" then
-- Script_variablesprite: two bytes, `wVariableSprites + slot = sprite`.
-- The slot operand is already the offset from SPRITE_VARS.
if args[1] and args[2] then
sprites[#sprites + 1] = { slot = args[1], sprite = args[2] }
end
end
pc = pc + 1 + size
if Opcodes.TERMINATORS[info.name] then break end
end
self:write("initial_events", {
generation = 2,
source = "ROM:InitializeEventsScript",
flags = flags,
engineFlags = engineFlags,
sprites = sprites,
})
end
-- PREDEFPAL_GAMEFREAK_LOGO_OB / _BG (constants/scgb_constants.asm), the two
-- palettes _CGB_GamefreakLogo loads: the OB pair is white/white/yellow/yellow
-- and the BG runs black to white.
-- constants/scgb_constants.asm, the constant immediately before
-- PREDEFPAL_GAMEFREAK_LOGO_OB.
local PREDEFPAL_UNOWN_PUZZLE = 76
local PREDEFPAL_GAMEFREAK_LOGO_OB = 77
local PREDEFPAL_GAMEFREAK_LOGO_BG = 78
-- The GameFreak Presents splash's own graphics (engine/movie/splash.asm
-- GameFreakPresentsInit). Both labels are two INCBINs run together, which is
-- the thing that used to be got wrong here: the 15 tiles at the START of
-- GameFreakLogoGFX are gamefreak_presents' letter strip, not the logo, so the
-- old "first 15 tiles are the mark" read wrote a slice of text glyphs and the
-- splash drew nothing recognisable.
--
-- GameFreakLogoGFX = gamefreak_presents.1bpp (13 tiles, vTiles1 $80-$8c)
-- + gamefreak_logo.1bpp (15 tiles, $8d-$9b)
-- GameFreakLogoStarsGFX = logo_star.2bpp (2 tiles, $9c-$9d)
-- + logo_sparkle.2bpp (3 tiles, $9e-$a0)
--
-- The logo's 15 tiles are laid out row-major by OAMData_GSGameFreakLogo (tile
-- $00 at the top-left of a 3x5 block, then left to right), so a 24x40 sheet
-- indexes straight off it. The star is 1x2 tiles and the OAM set mirrors it
-- to make the other half; the sparkle strip is 3 frames in a row.
function RomExtractorGen2:splashGfx()
if not (self.symbols.GameFreakLogoGFX
and self.symbols.GameFreakLogoStarsGFX) then
return nil
end
local gfx = self:symbol("GameFreakLogoGFX")
local raw = self.rom:bytes(gfx.bank, gfx.address, 28 * 8)
local presents, logo = {}, {}
for i = 1, 13 * 8 do presents[i] = raw[i] or 0 end
for i = 1, 15 * 8 do logo[i] = raw[13 * 8 + i] or 0 end
-- 1bpp, so black ink on transparent: the splash paints its own black field
-- and these draw over it through the palette the port picks.
self:save(inkFrom1bpp(presents, 104, 8), "splash/presents.png")
self:save(inkFrom1bpp(logo, 24, 40), "splash/logo.png")
-- Kept at the old path too: a cache reader that predates this table still
-- finds a logo image there, and now it is the right one.
self:save(inkFrom1bpp(logo, 24, 40), "intro/gamefreak_logo.png")
local stars = self:symbol("GameFreakLogoStarsGFX")
local starRaw = self.rom:bytes(stars.bank, stars.address, 2 * 16)
local sparkleRaw = self.rom:bytes(stars.bank, stars.address + 2 * 16, 3 * 16)
-- 2bpp with shade 0 transparent, the way every OBJ sheet is written.
self:write2bpp(starRaw, 8, 16, "splash/star.png", true)
self:write2bpp(sparkleRaw, 24, 8, "splash/sparkle.png", true)
return {
presents = "assets/generated/splash/presents.png",
logo = "assets/generated/splash/logo.png",
star = "assets/generated/splash/star.png",
sparkle = "assets/generated/splash/sparkle.png",
obPalette = self:predefPal(PREDEFPAL_GAMEFREAK_LOGO_OB),
bgPalette = self:predefPal(PREDEFPAL_GAMEFREAK_LOGO_BG),
}
end
-- OakSpeech (engine/menus/intro_menu.asm): named _OakText* strings plus the
-- POKEMON_PROF / CAL trainer pics shown before NamePlayer. Also pulls
-- Shrink1/2 pics and the GameFreak splash sheets for the boot cinema.
function RomExtractorGen2:extractOakSpeech(pokemon)
local charmap = self.manifest.charmap or {}
local texts = {}
for i = 1, 7 do
local label = ("_OakText%d"):format(i)
local sym = self:symbol(label)
texts[label] = self:decodeGen2Text(sym.bank, sym.address, charmap)
end
-- Trainer pics are always 7x7 tiles (GetTrainerPic / PlaceGraphic).
self:writeCompressedPic("PokemonProfPic", 7, "intro/oak.png")
self:writeCompressedPic("CalPic", 7, "intro/cal.png")
-- ShrinkPlayer frames (gfx/new_game/shrink{1,2}.2bpp.lz) : also 7x7.
if self.symbols.Shrink1Pic then
pcall(self.writeCompressedPic, self, "Shrink1Pic", 7, "intro/shrink1.png")
end
if self.symbols.Shrink2Pic then
pcall(self.writeCompressedPic, self, "Shrink2Pic", 7, "intro/shrink2.png")
end
local splash = self:splashGfx()
local marill = pokemon and pokemon.MARILL
local data = {
generation = 2,
source = "ROM:OakSpeech (_OakText1-7, PokemonProfPic, CalPic)",
music = "Music_Route30",
demoSpecies = "MARILL",
oakPic = "assets/generated/intro/oak.png",
playerPic = "assets/generated/intro/cal.png",
marillPic = marill and marill.spriteFront
or "assets/generated/battle/front/marill.png",
shrink1 = "assets/generated/intro/shrink1.png",
shrink2 = "assets/generated/intro/shrink2.png",
gamefreakLogo = "assets/generated/intro/gamefreak_logo.png",
splash = splash,
text = texts,
}
self:write("oak_speech", data)
return data
end
-- ItemNames only (attributes / effects / TMs stay Phase 2).
function RomExtractorGen2:extractItems()
self:beginStage("Items")
local order = self.manifest.constants and self.manifest.constants.itemOrder
if not order or #order == 0 then
self:write("items", {
generation = 2,
source = "Gold items: itemOrder missing from manifest : re-run make_gold_manifest.py",
})
self:tick("Items", 1, 1)
return {}
end
local charmap = self.manifest.charmap or {}
local consts = self.manifest.constants
local names = self:symbol("ItemNames")
local attributes = self:symbol("ItemAttributes")
local descriptions = self:symbol("ItemDescriptions")
local pocketOrder = consts.pocketOrder or {}
local menuOrder = consts.itemMenuOrder or {}
local heldOrder = consts.heldEffectOrder or {}
local out = {
generation = 2,
source = "ROM:ItemNames + ItemAttributes + constants/item_constants.asm",
pockets = pocketOrder,
}
-- ItemNames only has rows for item ids 1..NUM_ITEMS; the TM and HM items
-- past that carry no name of their own on the cart (the PACK prints
-- "TM08" from their TM number instead), so reading a name for them would
-- walk off the end of the table.
local nameCount = consts.itemNameCount or #order
local address = names.address
for index, itemId in ipairs(order) do
local value
if index <= nameCount then
local consumed
value, consumed = self.rom:readString(
names.bank, address, charmap, 0x50, 32)
address = address + consumed
end
if itemId and itemId ~= "UNUSED" then
-- ItemAttributes rows (ITEMATTR_STRUCT_LENGTH = 7): dw price;
-- db held effect, parameter, property, pocket; dn field menu, battle
-- menu. Rows are 1-based on item id, so MASTER_BALL (1) is row 0.
local base = attributes.address + (index - 1) * 7
local price = self.rom:word(attributes.bank, base)
local property = self.rom:byte(attributes.bank, base + 4)
local pocket = self.rom:byte(attributes.bank, base + 5)
local menus = self.rom:byte(attributes.bank, base + 6)
local descAddress = self.rom:word(
descriptions.bank, descriptions.address + (index - 1) * 2)
out[itemId] = {
id = itemId,
index = index,
name = value,
source = ("ROM:ItemNames[%d]"):format(index),
price = price,
heldEffect = heldOrder[self.rom:byte(attributes.bank, base + 2) + 1],
heldParameter = self.rom:byte(attributes.bank, base + 3),
-- `property` is a bitfield, not an enum: shift_const CANT_SELECT is
-- bit 6 and CANT_TOSS bit 7 (constants/item_data_constants.asm), so
-- NO_LIMITS = 0 means "both allowed".
canSelect = math.floor(property / 0x40) % 2 == 0,
canToss = math.floor(property / 0x80) % 2 == 0,
propertyRaw = property,
-- Item types are declared `const_def 1`, so ITEM is 1 and the list is
-- indexed by the value itself, not value + 1.
pocket = pocketOrder[pocket] or pocket,
pocketId = pocket,
-- dn field, battle: high nibble is the field menu behavior. The
-- ITEMMENU_* enum has a `const_skip 3` hole between NOUSE and CURRENT,
-- so a positional list cannot be indexed by value.
fieldMenu = ITEM_MENU_NAME[math.floor(menus / 16)],
battleMenu = ITEM_MENU_NAME[menus % 16],
description = self.rom:readString(
descriptions.bank, descAddress, charmap, 0x50, 128),
}
end
if index % 32 == 0 then
self:tick("Items", index, #order)
end
end
-- TM/HM items carry no name of their own in ItemNames: add_tm/add_hm name
-- the item TM_<MOVE> (the "TM08" form is only the TM##_MOVE alias) and give
-- it a TM/HM number, which is its position in that declaration order. The
-- move it teaches comes from TMHMMoves, indexed by that number -- which is
-- also what makes a species' BASE_TMHM bitfield readable.
local tmhmMoves = self:symbol("TMHMMoves")
local moveOrder = consts.moveOrder or {}
local number = 0
local hmCount = 0
for _, itemId in ipairs(order) do
local entry = out[itemId]
if entry and (itemId:match("^TM_") or itemId:match("^HM_")) then
number = number + 1
local moveId = self.rom:byte(
tmhmMoves.bank, tmhmMoves.address + number - 1)
entry.tmNumber = number
entry.teaches = moveOrder[moveId] or moveId
-- The label the PACK prints: TM01..TM50, then HM01 onwards (the HMs
-- restart their own numbering).
if itemId:match("^HM_") then
hmCount = hmCount + 1
entry.tmLabel = ("HM%02d"):format(hmCount)
else
entry.tmLabel = ("TM%02d"):format(number)
end
-- These items have no ItemNames row, so the label IS the name.
entry.name = entry.name or entry.tmLabel
end
end
self:write("items", out)
self:tick("Items", #order, #order)
return out
end
-- Mart shelves (data/items/marts.asm). Marts is NUM_MARTS same-bank `dw`
-- pointers in MART_* order; each list is `db count`, count item ids, `db -1`.
-- Written as `lists`, a 1-based array in that same order, so
-- src/ui/gen2/MartMenu.lua's inventory() indexes it with martId + 1 exactly
-- the way GetMart adds the id to the table. BargainShopData
-- (data/items/bargain_shop.asm) is its own `dbw item, price` rows -- the one
-- shop whose prices bypass ItemAttributes -- and lands under `bargain`.
--
-- Both symbols are post-Phase-2 manifest additions, so a manifest from before
-- them writes a marts.lua with no lists at all rather than failing the whole
-- import; MartMenu treats that as the empty shelf it already handles.
local NUM_MARTS = 34 -- constants/mart_constants.asm, MART_UNDERGROUND is 33
function RomExtractorGen2:extractMarts()
self:beginStage("Marts")
local order = (self.manifest.constants
and self.manifest.constants.itemOrder) or {}
local out = {
generation = 2,
source = "ROM:Marts + BargainShopData (data/items/marts.asm)",
}
local marts = self.symbols["Marts"]
if marts then
local bank = marts[1]
local lists = {}
for index = 0, NUM_MARTS - 1 do
local address = self.rom:word(bank, marts[2] + index * 2)
local count = self.rom:byte(bank, address)
local list = {}
for slot = 1, count do
local id = self.rom:byte(bank, address + slot)
if id == 0xff then break end
list[#list + 1] = order[id] or id
end
lists[index + 1] = list
end
out.lists = lists
end
local bargain = self.symbols["BargainShopData"]
if bargain then
local bank, address = bargain[1], bargain[2] + 1 -- past the count byte
local rows = {}
while true do
local id = self.rom:byte(bank, address)
if id == 0xff then break end
rows[#rows + 1] = {
item = order[id] or id,
price = self.rom:word(bank, address + 1),
}
address = address + 3
end
out.bargain = rows
end
self:write("marts", out)
self:tick("Marts", 1, 1)
return out
end
-- Moves + type chart. Shapes deliberately match Gen 1's moves.lua and
-- type_chart.lua so src/battle/TypeChart.lua reads either generation, but the
-- contents are Gen 2's: 251 moves, an effect-chance byte, and the Steel/Dark
-- rows that make the matchup table longer than Gen 1's.
function RomExtractorGen2:extractMoves()
self:beginStage("Moves")
local consts = self.manifest.constants
local order = consts.moveOrder or {}
local effects = consts.moveEffectOrder or {}
local typeById = {}
for name, value in pairs(consts.types or {}) do typeById[value] = name end
local charmap = self.manifest.charmap or {}
local moves = self:symbol("Moves")
local names = self:symbol("MoveNames")
local descriptions = self:symbol("MoveDescriptions")
local out = { generation = 2, source = "ROM:Moves + MoveNames" }
local nameAddress = names.address
for index, moveId in ipairs(order) do
local row = self.rom:bytes(
moves.bank, moves.address + (index - 1) * 7, 7)
local name, consumed = self.rom:readString(
names.bank, nameAddress, charmap, 0x50, 32)
nameAddress = nameAddress + consumed
-- Descriptions are a pointer table into the same bank, one per move.
local descAddress = self.rom:word(
descriptions.bank, descriptions.address + (index - 1) * 2)
local description = self.rom:readString(
descriptions.bank, descAddress, charmap, 0x50, 128)
if moveId and moveId ~= "UNUSED" then
out[moveId] = {
id = moveId,
index = index,
name = name,
source = ("ROM:Moves[%d]"):format(index),
animation = row[1],
effect = effects[row[2] + 1] or row[2],
effectId = row[2],
power = row[3],
type = typeById[row[4]] or row[4],
-- `db N percent` stores 255*N/100, so 100% is $ff; convert back to a
-- real percentage the way Gen 1's moves.lua reads (BLIZZARD 90, not
-- $e6). The raw byte is kept for anything that wants to reproduce the
-- cart's out-of-256 accuracy roll exactly.
accuracy = percentOf(row[5]),
accuracyRaw = row[5],
pp = row[6],
effectChance = percentOf(row[7]),
effectChanceRaw = row[7],
}
out[moveId].description = description
end
if index % 32 == 0 then self:tick("Moves", index, #order) end
end
self:write("moves", out)
-- TypeMatchups: attacker/defender/x10 triples. -2 ends the normal rows and
-- starts the Foresight-only block (which removes Ghost's immunities); -1
-- ends the table. Both blocks are kept, tagged, so a Foresight
-- implementation has the rows without re-reading the cart.
local matchupSymbol = self:symbol("TypeMatchups")
local matchups, foresight = {}, {}
local target = matchups
local offset = 0
while offset < 0x400 do
local attacker = self.rom:byte(
matchupSymbol.bank, matchupSymbol.address + offset)
if attacker == 0xff then break end
if attacker == 0xfe then
target = foresight
offset = offset + 1
else
local defender = self.rom:byte(
matchupSymbol.bank, matchupSymbol.address + offset + 1)
local multiplier = self.rom:byte(
matchupSymbol.bank, matchupSymbol.address + offset + 2)
target[#target + 1] = {
attacker = typeById[attacker] or attacker,
defender = typeById[defender] or defender,
multiplier = multiplier,
}
offset = offset + 3
end
end
-- Gen 2 still splits physical/special by *type*, not by move: ids below
-- SPECIAL are physical. The type constants leave a gap before the special
-- block, so the boundary is the numeric id, not a position in a list.
local specialBoundary = (consts.types and consts.types.FIRE) or 0x14
local typeNames = self:symbol("TypeNames")
local names2, records = {}, {}
for name, value in pairs(consts.types or {}) do
local pointer = self.rom:word(
typeNames.bank, typeNames.address + value * 2)
local display = self.rom:readString(
typeNames.bank, pointer, charmap, 0x50, 16)
names2[name] = display
records[name] = {
id = name, index = value, name = display,
category = (value < specialBoundary) and "physical" or "special",
}
end
self:write("type_chart", {
generation = 2,
source = "ROM:TypeMatchups + TypeNames",
names = names2,
types = records,
matchups = matchups,
foresightMatchups = foresight,
})
self:tick("Moves", #order, #order)
return out
end
-- Wild encounters. Grass tables carry three separate 7-slot lists (morn /
-- day / nite) plus a per-time encounter rate, which is the Gen 2 mechanic
-- that makes the clock part of gameplay rather than just lighting.
local GRASS_SLOTS = 7 -- NUM_GRASSMON
local WATER_SLOTS = 3 -- NUM_WATERMON
local GRASS_RECORD = 2 + 3 + GRASS_SLOTS * 2 * 3 -- GRASS_WILDDATA_LENGTH (47)
local WATER_RECORD = 2 + 1 + WATER_SLOTS * 2 -- WATER_WILDDATA_LENGTH (9)
function RomExtractorGen2:readGrassTable(symbolName)
local symbol = self:symbol(symbolName)
local out = {}
local offset = 0
-- Terminator-driven, but bounded: a bad pointer must fail the import loudly
-- rather than walk the whole cart.
for _ = 1, 512 do
local group = self.rom:byte(symbol.bank, symbol.address + offset)
if group == 0xff then break end
local mapNum = self.rom:byte(symbol.bank, symbol.address + offset + 1)
local mapId = self:mapNameByIds(group, mapNum)
local rates = {}
for i = 0, 2 do
rates[DAYTIMES[i + 1]] = self.rom:byte(
symbol.bank, symbol.address + offset + 2 + i)
end
local slots = {}
for day = 0, 2 do
local list = {}
for slot = 0, GRASS_SLOTS - 1 do
local base = symbol.address + offset + 5
+ (day * GRASS_SLOTS + slot) * 2
list[slot + 1] = {
level = self.rom:byte(symbol.bank, base),
species = self:speciesName(
self.rom:byte(symbol.bank, base + 1)),
}
end
slots[DAYTIMES[day + 1]] = list
end
if mapId then
out[mapId] = { map = mapId, rates = rates, slots = slots }
end
offset = offset + GRASS_RECORD
end
return out
end
function RomExtractorGen2:readWaterTable(symbolName)
local symbol = self:symbol(symbolName)
local out = {}
local offset = 0
for _ = 1, 512 do
local group = self.rom:byte(symbol.bank, symbol.address + offset)
if group == 0xff then break end
local mapNum = self.rom:byte(symbol.bank, symbol.address + offset + 1)
local mapId = self:mapNameByIds(group, mapNum)
local rate = self.rom:byte(symbol.bank, symbol.address + offset + 2)
local slots = {}
for slot = 0, WATER_SLOTS - 1 do
local base = symbol.address + offset + 3 + slot * 2
slots[slot + 1] = {
level = self.rom:byte(symbol.bank, base),
species = self:speciesName(self.rom:byte(symbol.bank, base + 1)),
}
end
if mapId then
out[mapId] = { map = mapId, rate = rate, slots = slots }
end
offset = offset + WATER_RECORD
end
return out
end
function RomExtractorGen2:speciesName(index)
local order = self.manifest.constants.speciesOrder or {}
return order[index] or index
end
-- data/wild/treemon_maps.asm: `treemon_map` rows of a two-byte map_id plus one
-- TREEMON_SET_* byte, ending at -1. TreeMonMaps and RockMonMaps are the same
-- table read twice -- GetTreeMonSet takes the list in hl, so HEADBUTT passes
-- one and ROCK SMASH the other (engine/events/treemons.asm).
function RomExtractorGen2:readTreeMonMaps(symbolName)
local symbol = self:symbol(symbolName)
local out = {}
local offset = 0
while offset < 0x200 do
local group = self.rom:byte(symbol.bank, symbol.address + offset)
if group == 0xff then break end
local mapNum = self.rom:byte(symbol.bank, symbol.address + offset + 1)
local set = self.rom:byte(symbol.bank, symbol.address + offset + 2)
local mapId = self:mapNameByIds(group, mapNum)
if mapId then
out[mapId] = (self.manifest.constants.treeMonSetOrder or {})[set + 1]
or set
end
offset = offset + 3
end
return out
end
-- data/wild/roammon_maps.asm RoamMaps. A row is `map_id` (group, number), a
-- count byte, that many `map_id` pairs and a 0 that ends the row -- the 0 is
-- what `.Update`'s `.next` scan walks to when the start map does not match --
-- and -1 ends the table. ORDER IS BEHAVIOUR: `.Update` picks a connection by
-- a two-bit index into the row and JumpRoamMon picks a ROW by a four-bit
-- index, so a reordered table sends the beasts somewhere else.
function RomExtractorGen2:readRoamMaps()
local symbol = self:symbol("RoamMaps")
local out = {}
local at = symbol.address
for _ = 1, 64 do
local group = self.rom:byte(symbol.bank, at)
if group == 0xff then break end
local mapNum = self.rom:byte(symbol.bank, at + 1)
local count = self.rom:byte(symbol.bank, at + 2)
local to = {}
for i = 0, count - 1 do
local toId = self:mapNameByIds(
self.rom:byte(symbol.bank, at + 3 + i * 2),
self.rom:byte(symbol.bank, at + 4 + i * 2))
if toId then to[#to + 1] = toId end
end
local mapId = self:mapNameByIds(group, mapNum)
if mapId then out[#out + 1] = { map = mapId, to = to } end
at = at + 3 + count * 2 + 1
end
return out
end
-- data/wild/bug_contest_mons.asm. A shape of its own, and deliberately not
-- read through readGrassTable: there is no map key, no per-time rate and no
-- seven-slot list, just `db %, species, min, max` rows that
-- ChooseWildEncounter_BugContest (engine/overworld/events.asm) walks with
-- `ld de, 4`, subtracting each chance byte from a 0..99 roll until it borrows.
--
-- The list has no terminator. The ten real rows already add to 100, so the
-- walk cannot get past the eleventh -- whose chance byte is -1, i.e. "always"
-- -- and that row is what ends the read here. It is carried anyway, because
-- it is a row of the cart's table and it is the row a chance list edited
-- anywhere above it would fall through to.
local CONTEST_MON_RECORD = 4
function RomExtractorGen2:readContestMons()
local symbol = self:symbol("ContestMons")
local out = {}
for row = 0, 31 do
local base = symbol.address + row * CONTEST_MON_RECORD
-- Bounded the way the rod lists above are: a table with no terminator must
-- stop at the end of its bank rather than assert out of the import
-- coroutine.
if not romAddrOk(symbol.bank, base + CONTEST_MON_RECORD - 1) then break end
local raw = self.rom:bytes(symbol.bank, base, CONTEST_MON_RECORD)
out[row + 1] = {
chance = raw[1],
species = self:speciesName(raw[2]),
min = raw[3],
max = raw[4],
}
if raw[1] == 0xff then break end
end
return out
end
function RomExtractorGen2:extractEncounters()
self:beginStage("Wild encounters")
local grass = {}
for _, name in ipairs({ "JohtoGrassWildMons", "KantoGrassWildMons" }) do
self:trace("grass " .. name)
for mapId, entry in pairs(self:readGrassTable(name)) do
grass[mapId] = entry
end
end
self:tick("Wild encounters", 1, 3)
local water = {}
for _, name in ipairs({ "JohtoWaterWildMons", "KantoWaterWildMons" }) do
self:trace("water " .. name)
for mapId, entry in pairs(self:readWaterTable(name)) do
water[mapId] = entry
end
end
self:tick("Wild encounters", 2, 3)
-- FishGroups rows: chance byte then old/good/super rod pointers, each a
-- list of (cumulative chance, species, level) triples ending at 100%.
self:trace("fish groups")
local fish = self:symbol("FishGroups")
local fishGroups = {}
local function readRod(address)
local list = {}
for i = 0, 7 do
-- A rod list is terminated by its 100% row, not a length, so bail on a
-- pointer that has left the bank rather than letting Rom.offset assert
-- from inside the import coroutine.
if not romAddrOk(fish.bank, address + i * 3 + 2) then break end
local chance = self.rom:byte(fish.bank, address + i * 3)
local species = self.rom:byte(fish.bank, address + i * 3 + 1)
local level = self.rom:byte(fish.bank, address + i * 3 + 2)
list[#list + 1] = {
chance = chance,
species = self:speciesName(species),
level = level,
}
-- Rows are cumulative and the last one is 100% ($ff after `percent`).
if chance >= 0xfe then break end
end
return list
end
-- FishGroups has NUM_FISHGROUPS (13) rows, one per group *except*
-- FISHGROUP_NONE: the constants start at 0 with NONE, so the table's row for
-- a group is its id minus one. Walking fishGroupOrder from position 1 reads
-- every row shifted by one and runs a row off the end of the table.
local fishOrder = self.manifest.constants.fishGroupOrder or {}
for index = 2, #fishOrder do
local groupId = fishOrder[index]
local row = index - 2
local base = fish.address + row * 7
fishGroups[groupId] = {
id = groupId,
index = index - 1,
chance = self.rom:byte(fish.bank, base),
old = readRod(self.rom:word(fish.bank, base + 1)),
good = readRod(self.rom:word(fish.bank, base + 3)),
super = readRod(self.rom:word(fish.bank, base + 5)),
}
end
-- Headbutt trees: map -> TREEMON_SET_*, and each set's common/rare lists.
self:trace("headbutt trees")
local trees = self:readTreeMonMaps("TreeMonMaps")
-- RockMonMaps, the four maps whose smashable rocks can hold a wild mon
-- (Cianwood, Route 40, Dark Cave Violet Entrance, Slowpoke Well B1F). Gated
-- on the symbol so an older manifest still imports; RockMonEncounter answers
-- "nothing here" for every map when the table is missing.
local rocks = self.symbols.RockMonMaps
and self:readTreeMonMaps("RockMonMaps") or nil
-- TreeMons: a pointer per TREEMON_SET_*, each aiming at TWO `db %, species,
-- level` lists back to back -- the common one and the rare one -- with a
-- -1 between them. Which of the two is rolled comes from how hard the tree
-- was hit (engine/events/treemons.asm), so both are carried here.
local treeSets = {}
local treeMonsSymbol = self.symbols["TreeMons"] and self:symbol("TreeMons")
if treeMonsSymbol then
local setOrder = self.manifest.constants.treeMonSetOrder or {}
for index, name in ipairs(setOrder) do
local pointer = self.rom:word(treeMonsSymbol.bank,
treeMonsSymbol.address + (index - 1) * 2)
local at = pointer
local lists = {}
for _ = 1, 2 do
local rows = {}
for _ = 1, 16 do
local chance = self.rom:byte(treeMonsSymbol.bank, at)
if chance == 0xff then
at = at + 1
break
end
local species = self.rom:byte(treeMonsSymbol.bank, at + 1)
local level = self.rom:byte(treeMonsSymbol.bank, at + 2)
rows[#rows + 1] = {
chance = chance,
species = (self.manifest.constants.speciesOrder or {})[species],
level = level,
}
at = at + 3
end
lists[#lists + 1] = rows
end
treeSets[name] = { common = lists[1] or {}, rare = lists[2] or {} }
end
end
-- The Bug Catching Contest's own table. It sits beside the grass rather
-- than inside it because the park's encounters come from HERE for the
-- twenty minutes the contest runs and from NATIONAL_PARK's grass row the
-- rest of the time.
-- Gated on the symbol the same way TreeMons above is, so an older manifest
-- still imports and BugContest.MONS carries the park.
self:trace("bug contest mons")
local bugContest = self.symbols.ContestMons and self:readContestMons() or nil
-- The two tables that sit IN FRONT of the ordinary ones. Swarms are the
-- same grass / water records keyed by map (_SwarmWildmonCheck searches them
-- before the Johto list), and RoamMaps is where the three beasts may walk.
-- Both were hand-written in src/core/gen2/Roamers.lua until the extractor
-- reached them; that table stays as the fallback for an older cache.
self:trace("swarms and roam maps")
local swarmGrass = self.symbols.SwarmGrassWildMons
and self:readGrassTable("SwarmGrassWildMons") or nil
local swarmWater = self.symbols.SwarmWaterWildMons
and self:readWaterTable("SwarmWaterWildMons") or nil
local roamMaps = self.symbols.RoamMaps and self:readRoamMaps() or nil
local data = {
generation = 2,
source = "ROM:JohtoGrassWildMons/KantoGrassWildMons/*WaterWildMons/FishGroups",
grass = grass,
water = water,
fishGroups = fishGroups,
trees = trees,
rocks = rocks,
treeSets = treeSets,
bugContest = bugContest,
swarmGrass = swarmGrass,
swarmWater = swarmWater,
roamMaps = roamMaps,
}
self:trace("writing encounters.lua")
self:write("encounters", data)
self:tick("Wild encounters", 3, 3)
return data
end
-- Trainers. TrainerGroups is indexed by trainer class - 1 (the table starts
-- at FALKNER = 1); each group is a run of variable-length parties whose shape
-- depends on the party's own TRAINERTYPE_* byte, ending at -1.
function RomExtractorGen2:extractTrainers()
self:beginStage("Trainers")
local consts = self.manifest.constants
local classOrder = consts.trainerClassOrder or {}
local members = consts.trainerClassMembers or {}
local moveOrder = consts.moveOrder or {}
local itemOrder = consts.itemOrder or {}
local charmap = self.manifest.charmap or {}
local groups = self:symbol("TrainerGroups")
local trainers = self:symbol("Trainers")
local classNames = self:symbol("TrainerClassNames")
local attributes = self:symbol("TrainerClassAttributes")
-- data/trainers/encounter_music.asm: `table_width 1`, one MUSIC_* id per
-- trainer class starting at class 0 (TRAINER_NONE), which is what
-- PlayTrainerEncounterMusic plays while the trainer walks up to you -- the
-- short encounter jingle, not the battle theme that follows it.
local encounterMusic = self:symbol("TrainerEncounterMusic")
local musicOrder = consts.musicOrder or {}
local out = {
generation = 2,
source = "ROM:TrainerGroups + Trainers + TrainerClassNames"
.. " + TrainerEncounterMusic",
classes = {},
}
-- TrainerClassNames is a plain @-terminated list in class order, starting
-- at class 1 (TRAINER_NONE has no name row).
local nameAddress = classNames.address
local classDisplay = {}
for index = 2, #classOrder do
local value, consumed = self.rom:readString(
classNames.bank, nameAddress, charmap, 0x50, 24)
nameAddress = nameAddress + consumed
classDisplay[classOrder[index]] = value
end
local typeOrder = consts.trainerTypeOrder or {}
for classIndex = 2, #classOrder do
local className = classOrder[classIndex]
local classId = classIndex - 1
local pointer = self.rom:word(
groups.bank, groups.address + (classId - 1) * 2)
local address = pointer
local parties = {}
local memberNames = members[className] or {}
-- A group has no end marker: FalknerGroup's single party is followed
-- straight by WhitneyGroup's label, so a scan that only watches for the
-- party's own -1 walks through every remaining group and off the bank.
-- The count comes from trainer_constants.asm (each `trainerclass` opens a
-- const_def whose entries ARE that group's trainers), with the next
-- group's pointer as the belt-and-braces bound.
local expected = #memberNames
local nextPointer = 0x8000
if classIndex < #classOrder then
local following = self.rom:word(
groups.bank, groups.address + classId * 2)
if following > pointer then nextPointer = following end
end
while (expected > 0 and #parties < expected)
or (expected == 0 and #parties < 1) do
if address >= nextPointer or not romAddrOk(trainers.bank, address) then
break
end
local first = self.rom:byte(trainers.bank, address)
if first == 0xff then break end
local name, consumed = self.rom:readString(
trainers.bank, address, charmap, 0x50, 16)
address = address + consumed
local trainerType = self.rom:byte(trainers.bank, address)
address = address + 1
local hasMoves = trainerType == 1 or trainerType == 3
local hasItem = trainerType == 2 or trainerType == 3
local party = {}
while #party < 6 do
local level = self.rom:byte(trainers.bank, address)
if level == 0xff then break end
local species = self.rom:byte(trainers.bank, address + 1)
address = address + 2
local mon = {
level = level,
species = self:speciesName(species),
}
if hasItem then
mon.item = itemOrder[self.rom:byte(trainers.bank, address)]
address = address + 1
end
if hasMoves then
local moves = {}
for i = 0, 3 do
local moveId = self.rom:byte(trainers.bank, address + i)
if moveId ~= 0 then moves[#moves + 1] = moveOrder[moveId] end
end
address = address + 4
mon.moves = moves
end
party[#party + 1] = mon
end
-- Skip the -1 that ends this party.
address = address + 1
parties[#parties + 1] = {
id = memberNames[#parties + 1] or
("%s%d"):format(className, #parties + 1),
index = #parties + 1,
name = name,
trainerType = typeOrder[trainerType + 1] or trainerType,
party = party,
}
end
-- TrainerClassAttributes is SEVEN bytes a class, not eight:
-- NUM_TRAINER_ATTRIBUTES is `_RS` after three `rb` and two `rw`
-- (constants/trainer_data_constants.asm):
-- 1-2 TRNATTR_ITEM1 / ITEM2 the two items the trainer may use
-- 3 TRNATTR_BASEMONEY the reward multiplier
-- 4-5 TRNATTR_AI_MOVE_WEIGHTS which scoring layers run
-- 6-7 TRNATTR_AI_ITEM_SWITCH how eager it is to rotate, and when it
-- is allowed to reach for an item
-- An eight-byte stride walks a byte further off with every class, which is
-- why the AI flags used to be noise past the first few trainers.
local attrRow = self.rom:bytes(
attributes.bank, attributes.address + (classId - 1) * 7, 7)
local carried = {}
for slot = 1, 2 do
local name = itemOrder[attrRow[slot]]
if name and name ~= "NO_ITEM" then carried[#carried + 1] = name end
end
local musicId = self.rom:byte(
encounterMusic.bank, encounterMusic.address + classId)
out.classes[className] = {
id = className,
index = classId,
name = classDisplay[className] or className,
encounterMusic = musicOrder[(musicId or 0) + 1],
-- Row 3, not row 1: the first two bytes are the items.
baseMoney = attrRow[3],
attributes = attrRow,
items = carried,
trainers = parties,
}
self:tick("Trainers", classIndex, #classOrder)
end
self:write("trainers", out)
return out
end
-- callstd / jumpstd targets. StdScripts is a `dba` table (bank $40), and its
-- ids are exactly the stdScriptOrder the manifest scraped from
-- engine/events/std_scripts.asm. Resolving them is what lets
-- ReceiveItemScript, the mart/statue helpers and the phone-number scripts run
-- without every map duplicating their text.
--
-- The bodies themselves are disassembled by extractScriptsAndText, which seeds
-- its walk from `key` here -- one code path for map and std scripts alike.
function RomExtractorGen2:extractStdScripts()
self:beginStage("Std scripts")
local order = self.manifest.constants.stdScriptOrder or {}
local table_ = self:symbol("StdScripts")
local out = {
generation = 2,
source = "ROM:StdScripts + engine/events/std_scripts.asm order",
order = order,
scripts = {},
-- id -> label, so a `callstd 2` in a disassembled script reads as a name.
byId = {},
}
for index, label in ipairs(order) do
local base = table_.address + (index - 1) * 3
local bank = self.rom:byte(table_.bank, base)
local address = self.rom:word(table_.bank, base + 1)
local entry = {
id = label,
index = index - 1,
bank = bank,
address = address,
key = Opcodes.key(bank, address),
}
out.scripts[label] = entry
out.byId[index - 1] = label
self:tick("Std scripts", index, #order)
end
self:write("std_scripts", out)
return out
end
-- Pokedex: the two alternate orderings the #DEX screen can sort by, plus each
-- species' entry (kind name, height/weight, description pages).
function RomExtractorGen2:extractPokedex()
self:beginStage("Pokedex")
local consts = self.manifest.constants
local speciesOrder = consts.speciesOrder or {}
local charmap = self.manifest.charmap or {}
-- Entries are spread over four banks ("Pokedex Entries 001-064" and
-- friends) and the game picks the bank by rotating the species id
-- (engine/pokegear/radio.asm), so each entry's own symbol is the reliable
-- address -- the same reason pic labels are resolved per species.
local entries = {}
for index, species in ipairs(speciesOrder) do
local asset = self.manifest.pokemonAssets[species]
local symbol = asset and asset.dexLabel and self.symbols[asset.dexLabel]
if symbol and species ~= "UNUSED" then
local bank, address = symbol[1], symbol[2]
local kind, consumed = self.rom:readString(
bank, address, charmap, 0x50, 24)
local sizeAt = address + consumed
-- Both little-endian, and both are already the digits DisplayDexEntry
-- prints rather than a physical unit: the height word is fed to PrintNum
-- as 4 digits with 2 in front of the point (204 -> 2'04") and the weight
-- word as 5 digits with 4 in front (150 -> 15.0 lb).
local height = self.rom:word(bank, sizeAt)
local weight = self.rom:word(bank, sizeAt + 2)
-- The description is two pages: the `page` macro is a bare "@", so the
-- second page begins where the first one's terminator left off and
-- GetDexEntryPagePointer finds it by walking past that @.
local text, page1Length = self.rom:readString(
bank, sizeAt + 4, charmap, 0x50, 256)
local text2 = self.rom:readString(
bank, sizeAt + 4 + page1Length, charmap, 0x50, 256)
entries[species] = {
id = species, dex = index,
kind = kind, height = height, weight = weight,
text = text, text2 = text2,
}
end
if index % 32 == 0 then self:tick("Pokedex", index, #speciesOrder) end
end
local function readOrder(symbolName, length)
local symbol = self:symbol(symbolName)
local list = {}
for i = 0, length - 1 do
local id = self.rom:byte(symbol.bank, symbol.address + i)
list[i + 1] = speciesOrder[id] or id
end
return list
end
local data = {
generation = 2,
source = "ROM:PokedexDataPointerTable + NewPokedexOrder",
entries = entries,
-- New (Johto) order is what Gold's #DEX lists by default; alphabetical is
-- the other sort the screen offers.
newOrder = readOrder("NewPokedexOrder", #speciesOrder),
alphabeticalOrder = readOrder("AlphabeticalPokedexOrder", #speciesOrder),
}
self:write("pokedex", data)
self:tick("Pokedex", #speciesOrder, #speciesOrder)
return data
end
-- Pokegear town map: each landmark's pixel position and name. The macro adds
-- the screen origin (x + 8, y + 16) so the values are already OAM
-- coordinates; store them back in tilemap space, which is what a UI wants.
function RomExtractorGen2:extractLandmarks()
self:beginStage("Landmarks")
local order = self.manifest.constants.landmarkOrder or {}
local symbol = self:symbol("Landmarks")
local charmap = self.manifest.charmap or {}
local out = { generation = 2, source = "ROM:Landmarks", order = order,
landmarks = {} }
for index, id in ipairs(order) do
local base = symbol.address + (index - 1) * 4
local x = self.rom:byte(symbol.bank, base) - 8
local y = self.rom:byte(symbol.bank, base + 1) - 16
local pointer = self.rom:word(symbol.bank, base + 2)
local name = self.rom:readString(
symbol.bank, pointer, charmap, 0x50, 24)
-- charmap.asm: <BSP> ($1f) is a "breakable space", which the Town Map
-- renders as a line break -- that is what splits NEW BARK / TOWN onto two
-- rows. Store it as a newline so a UI can lay it out either way.
name = tostring(name):gsub("<BSP>", "\n")
out.landmarks[id] = { id = id, index = index - 1, x = x, y = y, name = name }
self:tick("Landmarks", index, #order)
end
-- SpawnPoints (data/maps/spawn_points.asm): map_id + x/y, one row per
-- SPAWN_*. SPAWN_HOME is where a New Game puts the player -- the bedroom
-- upstairs in the player's house, not outside (intro_menu.asm NewGame sets
-- wDefaultSpawnpoint = SPAWN_HOME and warps there). The rest are Pokecenter
-- respawns.
local spawnSymbol = self:symbol("SpawnPoints")
out.spawns = {}
for index, id in ipairs(self.manifest.constants.spawnOrder or {}) do
local base = spawnSymbol.address + (index - 1) * 4
local group = self.rom:byte(spawnSymbol.bank, base)
local mapNum = self.rom:byte(spawnSymbol.bank, base + 1)
out.spawns[id] = {
id = id,
index = index - 1,
map = self:mapNameByIds(group, mapNum),
x = self.rom:byte(spawnSymbol.bank, base + 2),
y = self.rom:byte(spawnSymbol.bank, base + 3),
}
end
self:write("landmarks", out)
return out
end
-- Party-menu mon icons: 16x32 sheets (two 16x16 animation frames stacked),
-- shared between species via MonMenuIcons.
local ICON_TILES = 8
function RomExtractorGen2:extractIcons()
self:beginStage("Menu icons")
local consts = self.manifest.constants
local iconOrder = consts.iconOrder or {}
local speciesOrder = consts.speciesOrder or {}
local pointers = self:symbol("IconPointers")
local icons = self:symbol("Icons")
local out = { generation = 2, source = "ROM:IconPointers + MonMenuIcons",
icons = {}, species = {} }
for index, iconId in ipairs(iconOrder) do
local address = self.rom:word(
pointers.bank, pointers.address + (index - 1) * 2)
local raw = self.rom:bytes(icons.bank, address, ICON_TILES * 16)
local base = iconId:lower():gsub("^icon_", "")
local rel = "icons/gen2/" .. base .. ".png"
-- Icons are OBJ sprites, so shade 0 is the transparent color.
self:write2bpp(raw, 16, 32, rel, true)
out.icons[iconId] = {
id = iconId, index = index - 1,
image = "assets/generated/" .. rel,
width = 16, height = 32, frames = 2,
}
self:tick("Menu icons", index, #iconOrder)
end
local monIcons = self:symbol("MonMenuIcons")
for index, species in ipairs(speciesOrder) do
local iconId = self.rom:byte(
monIcons.bank, monIcons.address + (index - 1))
out.species[species] = iconOrder[iconId + 1] or iconId
end
-- .SpawnItemIcon (engine/gfx/mon_icons.asm:218-228): a party mon holding
-- something swaps its icon's BOTTOM-LEFT tile for one of the two
-- HeldItemIcons tiles (gfx/stats/mail.2bpp then gfx/stats/item.2bpp), which
-- GetIconGFX uploads straight after the eight icon tiles. One 8x16 sheet,
-- mail on top: the row order is load bearing, it is what
-- src/ui/gen2/PartyMenu.lua heldMarkerRow indexes with.
-- Tolerated rather than required, like UnownFont above: a manifest built
-- before the symbol was listed still imports, it just leaves the markers
-- undrawn.
if self.symbols["HeldItemIcons"] then
local markers = self:symbol("HeldItemIcons")
local rel = "icons/gen2/held_item_markers.png"
-- OBJ tiles, so shade 0 is transparent, the same as the icons above.
self:write2bpp(self.rom:bytes(markers.bank, markers.address, 2 * 16),
8, 16, rel, true)
out.heldItem = { image = "assets/generated/" .. rel,
width = 8, height = 8, mailRow = 0, itemRow = 1 }
end
self:write("icons", out)
return out
end
-- The Gold/Silver intro movie's data (engine/movie/intro.asm).
--
-- The water and grass acts build their background the same way
-- (Intro_DrawBackground / Intro_Draw2x2Tiles): a compressed tile sheet, a
-- metatile table where every entry is four tile ids in 2x2 order, and a grid
-- of metatile indices laid across the whole 32x32 BG map. None of it is
-- composed into a finished picture here, because the movie keeps editing the
-- map as it plays -- the water act streams a fresh metatile row in at the top
-- every 16 pixels of climb (Intro_UpdateTilemapAndBGMap) and repaints BG row
-- 15 from a four-frame wave cycle (Intro_AnimateOceanWaves), and the fire act
-- writes its Charizard rectangles straight into the map
-- (DrawIntroCharizardGraphic). So the tables ship as tables and
-- src/ui/gen2/GoldSilverIntro.lua runs the same routines over them.
--
-- Tile sheets are written 16 tiles per row so a tile id resolves to
-- (id % 16, id / 16). That is what data/sprite_anims/oam.asm assumes -- a
-- 4x4 OBJ is $00..$03 over $10..$13 -- and what the BG maps assume too.
local INTRO_TILEMAP_WIDTH = 16 -- TILEMAP_WIDTH / 2, counted in metatiles
local INTRO_METATILE_LENGTH = 4
local INTRO_SHEET_TILES = 16
-- The water tilemap is 16 metatiles wide by 32 tall; IntroScene1 starts
-- reading it at `Intro_WaterTilemap + 15 tiles` (row 15) and the climb walks
-- backwards a row at a time from there. The grass tilemap is just the 16
-- rows that fill the BG map once.
local INTRO_WATER_TILEMAP_ROWS = 32
local INTRO_GRASS_TILEMAP_ROWS = 16
local INTRO_WATER_FIRST_ROW = 15
-- constants/scgb_constants.asm PREDEFPAL_*, for the palettes _CGB_GSIntro
-- pulls out of the shared pool rather than carrying inline.
local PREDEFPAL_GS_INTRO_JIGGLYPUFF_PIKACHU_BG = 56
local PREDEFPAL_GS_INTRO_JIGGLYPUFF_PIKACHU_OB = 57
local PREDEFPAL_GS_INTRO_STARTERS_TRANSITION = 58
-- OAMData_GSIntroStarter lays 25 tiles in five columns of five, which is how
-- big the three Johto starters' front pics are.
local INTRO_STARTER_TILES = 25
-- IntroScene10's three `Intro_GetMonFrontpic` destinations, as tile ids in
-- the act's OBJ sheet (vTiles0).
local INTRO_STARTER_VTILES = { 0x10, 0x29, 0x42 }
-- Pads a decompressed 2bpp stream out to whole 16-tile rows and writes it as
-- one sheet. Returns the asset path.
function RomExtractorGen2:writeIntroSheet(pixels, relative, transparent)
local tiles = math.floor(#pixels / 16)
local rows = math.max(1, math.ceil(tiles / INTRO_SHEET_TILES))
local length = rows * INTRO_SHEET_TILES * 16
while #pixels > length do table.remove(pixels) end
while #pixels < length do pixels[#pixels + 1] = 0 end
self:write2bpp(pixels, INTRO_SHEET_TILES * 8, rows * 8, relative, transparent)
return "assets/generated/" .. relative
end
-- One act's BG data: the tile sheet plus the two tables Intro_Draw2x2Tiles
-- reads. Both tables ship as flat 1-based byte arrays; the metatile count is
-- taken from the highest index the grid actually names, so the table's end
-- does not have to be bounded by the next symbol.
function RomExtractorGen2:introBackground(gfxLabel, metaLabel, tilemapLabel,
tilemapRows, relative)
local out = {}
-- BG sheets are written with colour 0 transparent so a priority OBJ shows
-- through exactly where the hardware would let it; the backdrop the movie
-- draws under everything is BG palette colour 0.
out.tiles = self:writeIntroSheet(
self:decompressLz3Symbol(gfxLabel), relative, true)
local tilemap = self:symbol(tilemapLabel)
local grid, highest = {}, 0
for index = 0, tilemapRows * INTRO_TILEMAP_WIDTH - 1 do
local value = self.rom:byte(tilemap.bank, tilemap.address + index)
grid[index + 1] = value
if value > highest then highest = value end
end
out.tilemap = grid
out.tilemapRows = tilemapRows
local meta = self:symbol(metaLabel)
local metatiles = {}
for index = 0, (highest + 1) * INTRO_METATILE_LENGTH - 1 do
metatiles[index + 1] = self.rom:byte(meta.bank, meta.address + index)
end
out.meta = metatiles
return out
end
-- PredefPals is a flat pool of 4-colour palettes; GetPredefPal indexes it by
-- the PREDEFPAL_* constant (engine/gfx/color.asm).
function RomExtractorGen2:predefPal(index)
local pals = self:symbol("PredefPals")
return self:colors(pals.bank, pals.address + index * 8, 4)
end
-- The movie's CGB palettes. Every act calls WipeAttrmap, so BG palette 0
-- colours the entire screen; an OBJ's palette is the low 3 bits of its OAM
-- attribute byte, which is why the water act needs two and the others one.
function RomExtractorGen2:introPalettes()
local waterBg = self:symbol("_CGB_GSIntro.ShellderLaprasBGPalette")
local waterOb = self:symbol("_CGB_GSIntro.ShellderLaprasOBPals")
local karpBg = self:symbol("Intro_LoadMagikarpPalettes.MagikarpBGPal")
local karpOb = self:symbol("Intro_LoadMagikarpPalettes.MagikarpOBPal")
-- _CGB_GSIntro.StartersCharizardScene runs CopyFourPalettes over
-- PalPacket_Pack + 1, so the fire act's BG palettes are four PREDEFPAL_*
-- indices stored inside that SGB packet.
local packet = self:symbol("PalPacket_Pack")
local fireBg = {}
for slot = 1, 4 do
fireBg[slot] = self:predefPal(
self.rom:byte(packet.bank, packet.address + slot))
end
return {
waterBg = self:colors(waterBg.bank, waterBg.address, 4),
waterOb = {
self:colors(waterOb.bank, waterOb.address, 4),
self:colors(waterOb.bank, waterOb.address + 8, 4),
},
magikarpBg = self:colors(karpBg.bank, karpBg.address, 4),
magikarpOb = self:colors(karpOb.bank, karpOb.address, 4),
grassBg = self:predefPal(PREDEFPAL_GS_INTRO_JIGGLYPUFF_PIKACHU_BG),
grassOb = self:predefPal(PREDEFPAL_GS_INTRO_JIGGLYPUFF_PIKACHU_OB),
startersOb = self:predefPal(PREDEFPAL_GS_INTRO_STARTERS_TRANSITION),
fireBg = fireBg,
}
end
function RomExtractorGen2:extractIntro()
self:beginStage("Intro movie")
local out = { generation = 2, source = "ROM:Intro_*GFX/Tilemap/Meta" }
-- Act 1, underwater.
out.water = self:introBackground("Intro_WaterGFX1", "Intro_WaterMeta",
"Intro_WaterTilemap", INTRO_WATER_TILEMAP_ROWS, "intro/water_tiles.png")
out.water.firstRow = INTRO_WATER_FIRST_ROW
out.water.sprites = self:writeIntroSheet(
self:decompressLz3Symbol("Intro_WaterGFX2"), "intro/water_sprites.png",
true)
self:tick("Intro movie", 1, 4)
-- Act 2, grass: same shape, its own sheet and grid, read from row 0.
out.grass = self:introBackground("Intro_GrassGFX1", "Intro_GrassMeta",
"Intro_GrassTilemap", INTRO_GRASS_TILEMAP_ROWS, "intro/grass_tiles.png")
out.grass.firstRow = 0
out.grass.sprites = self:writeIntroSheet(
self:decompressLz3Symbol("Intro_GrassGFX2"), "intro/grass_sprites.png",
true)
self:tick("Intro movie", 2, 4)
-- Act 3 has no tilemap: DrawIntroCharizardGraphic writes the silhouette's
-- rectangle of running tile ids into the map itself. Its BG tiles are
-- Intro_FireGFX1 at vTiles2 ($00-$7f) followed by Intro_FireGFX2 at
-- vTiles1 ($80-$cf), so the two decompress into one sheet.
local fireTiles = self:decompressLz3Symbol("Intro_FireGFX1")
while #fireTiles < 0x80 * 16 do fireTiles[#fireTiles + 1] = 0 end
while #fireTiles > 0x80 * 16 do table.remove(fireTiles) end
for _, byte in ipairs(self:decompressLz3Symbol("Intro_FireGFX2")) do
fireTiles[#fireTiles + 1] = byte
end
out.fire = {
tiles = self:writeIntroSheet(fireTiles, "intro/fire_tiles.png", true),
}
self:tick("Intro movie", 3, 4)
-- The act's OBJ sheet is Intro_FireGFX3 (the fireball) with the three
-- Johto starters' front pics decompressed over the top of it at $10/$29/$42.
-- Pics are stored column-major and OAMData_GSIntroStarter reads them that
-- way, so they go in as-is rather than through columnsToRows.
local fireSprites = self:decompressLz3Symbol("Intro_FireGFX3")
for index, label in ipairs({
"ChikoritaFrontpic", "CyndaquilFrontpic", "TotodileFrontpic",
}) do
local pic = self:decompressLz3Symbol(label)
local base = INTRO_STARTER_VTILES[index] * 16
for offset = 1, INTRO_STARTER_TILES * 16 do
fireSprites[base + offset] = pic[offset] or 0
end
end
out.fire.sprites = self:writeIntroSheet(fireSprites,
"intro/fire_sprites.png", true)
out.palettes = self:introPalettes()
self:write("intro", out)
self:tick("Intro movie", 4, 4)
return out
end
-- Menu chrome that is not part of the font: the naming screen's patterned
-- backdrop tile, its 2-tile cursor, and the middle/under line glyphs that mark
-- the name-entry field. These are loaded straight into VRAM by
-- LoadNamingScreenGFX rather than living in a font page, so nothing else in
-- the import would pick them up.
function RomExtractorGen2:extractMenuGfx()
self:beginStage("Menu graphics")
local out = { generation = 2, source = "ROM:NamingScreenGFX_*" }
-- Border is 2bpp and tiles the whole screen, so it stays opaque.
local border = self:symbol("NamingScreenGFX_Border")
self:write2bpp(self.rom:bytes(border.bank, border.address, 16), 8, 8,
"naming/border.png")
out.border = "assets/generated/naming/border.png"
-- Cursor is 2bpp and 2 tiles, drawn as an 8x16 OBJ (the naming screen's
-- cursor is a tall arrow, not a wide one), so the sheet is one tile wide by
-- two tall. It is an OBJ, so color 0 is transparent.
local cursor = self:symbol("NamingScreenGFX_Cursor")
self:write2bpp(self.rom:bytes(cursor.bank, cursor.address, 32), 8, 16,
"naming/cursor.png", true)
out.cursor = "assets/generated/naming/cursor.png"
-- Both lines are 1bpp; inkFrom1bpp gives black-on-transparent so they can be
-- drawn in whatever color the screen is using.
for key, label in pairs({
middleLine = "NamingScreenGFX_MiddleLine",
underLine = "NamingScreenGFX_UnderLine",
}) do
local symbol = self:symbol(label)
local raw = self.rom:bytes(symbol.bank, symbol.address, 8)
local rel = "naming/" .. key:lower() .. ".png"
self:save(inkFrom1bpp(raw, 8, 8), rel)
out[key] = "assets/generated/" .. rel
end
-- Battle HUD tiles (engine/gfx/load_font.asm LoadHPBar). The HUD is built
-- out of tiles on the cart, so extracting them is what makes the layout
-- align on the 8px grid by construction instead of by eye:
--
-- FontBattleExtra -> $60: "HP:" is $60/$61, then the HP bar's cells
-- $62 (empty) .. $6a (8px full) and $6b (end cap)
-- EnemyHPBarBorderGFX-> $6c: 4 tiles; $6d left side, $6f bottom left
-- HPExpBarBorderGFX -> $73: 6 tiles; $73 right side, $74 bottom left,
-- $76 bottom side, $77/$78 bottom right
-- ExpBarGFX -> $55: 9 exp-bar fill cells
--
-- The two border sheets are 1bpp, so they come out as black ink on
-- transparent and can be drawn in any color; the exp bar is 2bpp.
local hud = {}
local enemyBorder = self:symbol("EnemyHPBarBorderGFX")
self:save(inkFrom1bpp(
self.rom:bytes(enemyBorder.bank, enemyBorder.address, 4 * 8), 32, 8),
"battle/hud/enemy_border.png")
hud.enemyBorder = "assets/generated/battle/hud/enemy_border.png"
hud.enemyBorderFirstTile = 0x6c
local playerBorder = self:symbol("HPExpBarBorderGFX")
self:save(inkFrom1bpp(
self.rom:bytes(playerBorder.bank, playerBorder.address, 6 * 8), 48, 8),
"battle/hud/player_border.png")
hud.playerBorder = "assets/generated/battle/hud/player_border.png"
hud.playerBorderFirstTile = 0x73
-- The player's own back-pic, which stands in the player's box for the whole
-- battle intro. gfx/player/chris_back.png is 48x48, i.e. SIX tiles square:
-- GetTrainerBackpic's `ld c, 7 * 7` is how many tiles of VRAM it asks for,
-- not how big the pic is, and reading it as 7x7 scrambles every column.
-- Column-major like every other pic, hence columnsToRows.
if self.symbols["ChrisBackpic"] then
local ok = pcall(function()
self:writeCompressedPic("ChrisBackpic", 6, "battle/player_back.png")
end)
if ok then hud.playerBack = "assets/generated/battle/player_back.png" end
end
-- GetTrainerBackpic's "Special exception for Dude": the catching tutorial
-- draws DudeBackpic in that same box, from the same bank and at the same six
-- tiles square (src/core/gen2/CatchTutorial.lua).
if self.symbols["DudeBackpic"] then
local ok = pcall(function()
self:writeCompressedPic("DudeBackpic", 6, "battle/dude_back.png")
end)
if ok then hud.dudeBack = "assets/generated/battle/dude_back.png" end
end
-- TrainerPicPointers (data/trainers/pic_pointers.asm): one `dba_pic` per
-- trainer class, `table_width 3`. GetTrainerPic (engine/gfx/load_pics.asm:
-- 254-257) indexes it with `ld a, [wTrainerClass] / dec a / ld bc, 3`, so
-- row 0 is FALKNER and TRAINER_NONE has no row at all. Trainer pics are
-- always 7 tiles square (`ld c, 7 * 7`, :277) and, unlike a mon frontpic,
-- are never PadFrontpic'd.
if self.symbols["TrainerPicPointers"] then
local symbol = self:symbol("TrainerPicPointers")
local classOrder = (self.manifest.constants or {}).trainerClassOrder or {}
local pics = {}
for index, class in ipairs(classOrder) do
-- index 1 is TRAINER_NONE (class 0), which the `dec a` skips.
if index >= 2 then
local base = symbol.address + (index - 2) * 3
local bank = FIX_PIC_BANK[self.rom:byte(symbol.bank, base)]
or self.rom:byte(symbol.bank, base)
local address = self.rom:word(symbol.bank, base + 1)
local rel = ("battle/trainers/%s.png"):format(class:lower())
local ok, err = pcall(function()
-- Same bank-crossing read as the Unown pics: GetLZByte drops back
-- to $4000 in the next bank once the read pointer passes $8000.
local compressed = self.rom:bytes(bank, address, 0x8000 - address)
local nextBank = self.rom:bytes(bank + 1, 0x4000, 0x4000)
for _, byte in ipairs(nextBank) do
compressed[#compressed + 1] = byte
end
local pixels = Rom.decompressLz3(compressed)
local byteLength = 56 * 56 / 4
while #pixels < byteLength do pixels[#pixels + 1] = 0 end
while #pixels > byteLength do table.remove(pixels) end
pixels = ImageWriter.columnsToRows(pixels, 7, 7)
self:write2bpp(pixels, 56, 56, rel)
end)
if ok then
pics[class] = "assets/generated/" .. rel
else
self:trace(("trainer pic %s: %s"):format(class, tostring(err)))
end
end
end
hud.trainerPics = pics
end
local expBar = self:symbol("ExpBarGFX")
self:write2bpp(self.rom:bytes(expBar.bank, expBar.address, 9 * 16),
72, 8, "battle/hud/exp_bar.png")
hud.expBar = "assets/generated/battle/hud/exp_bar.png"
hud.expBarFirstTile = 0x55
hud.expBarCells = 9
-- "HP:" and the bar cells, as a plain 2bpp sheet rather than the ink-on-
-- transparent font page: the bar's rule is shade 3 (black) while its fill is
-- shade 1/2 (the HP colour), so flattening it to one ink loses the very
-- distinction that makes the bar readable. Drawn through GbcPalette with
-- palettes.hpBar, exactly like the cart colours it from HPBarPals.
local battleExtra = self:symbol("FontBattleExtra")
self:write2bpp(
self.rom:bytes(battleExtra.bank, battleExtra.address, 12 * 16),
96, 8, "battle/hud/hp_bar.png")
hud.hpBar = "assets/generated/battle/hud/hp_bar.png"
hud.hpBarTiles = 12
hud.battleExtra = "assets/generated/fonts/font_battle_extra.png"
hud.battleExtraFirstTile = 0x60
hud.hpLabelTiles = { 0x60, 0x61 }
hud.hpBarEmptyTile = 0x62
hud.hpBarFullTile = 0x6a
hud.hpBarEndTile = 0x6b
out.battleHud = hud
-- PACK (engine/items/pack.asm). The screen is built the same way the
-- battle HUD is -- out of the cart's own tiles at the cart's own coordinates
-- -- rather than from rectangles:
--
-- PackMenuGFX -> $00: $60 tiles. Pack_InitGFX copies the whole sheet to
-- vTiles2 tile $00, fills rows 1-11 with $24, and lays the
-- header strip at (0,0) as $28..$3b (20 running tiles).
-- PackGFX -> $50: four 15-tile (5x3) pack pictures. DrawPackGFX swaps
-- in the one for wCurPocket and PlacePackGFX lays $50..$5e
-- at (0,3). PackGFXPointers orders them KEY_ITEM, ITEM,
-- TM_HM, BALL in ROM, indexed by pocket.
-- pocket name -> DrawPocketName's 5x12 tilemap: four 5x3 blocks, laid at
-- (0,7), one per pocket in *_POCKET order.
-- palettes -> _CGB_PackPals: six BG palettes and the attrmap zones that
-- make the header halves, the pack picture and the pocket
-- name different colours.
local pack = {}
local packMenu = self:symbol("PackMenuGFX")
self:write2bpp(self.rom:bytes(packMenu.bank, packMenu.address, 0x60 * 16),
128, 48, "pack/menu.png")
pack.menu = "assets/generated/pack/menu.png"
pack.menuTiles = 0x60
pack.menuTilesWide = 16
pack.backgroundTile = 0x24
pack.headerFirstTile = 0x28
local packGfx = self:symbol("PackGFX")
self:write2bpp(self.rom:bytes(packGfx.bank, packGfx.address, 60 * 16),
40, 96, "pack/pack.png")
pack.pack = "assets/generated/pack/pack.png"
pack.packFirstTile = 0x50
pack.packTilesWide = 5
pack.packTilesHigh = 3
-- PackGFXPointers, as the tile row each pocket's picture starts on.
pack.pocketPicture = {
ITEM = 15, BALL = 45, KEY_ITEM = 0, TM_HM = 30,
}
local pocketMap = self:symbol("DrawPocketName.tilemap")
local pocketRaw = self.rom:bytes(pocketMap.bank, pocketMap.address, 60)
pack.pocketName = {}
for block = 0, 3 do
local tiles = {}
for i = 1, 15 do tiles[i] = pocketRaw[block * 15 + i] end
pack.pocketName[block + 1] = tiles
end
-- constants/item_data_constants.asm *_POCKET order.
pack.pocketOrder = { "ITEM", "BALL", "KEY_ITEM", "TM_HM" }
local packPals = self:symbol("_CGB_PackPals.PackPals")
pack.palettes = {}
for i = 0, 5 do
pack.palettes[i + 1] = self:colors(packPals.bank, packPals.address + i * 8, 4)
end
-- _CGB_PackPals' FillBoxCGB calls, as {x, y, width, height, palette}.
pack.paletteZones = {
{ 0, 0, 10, 1, 2 },
{ 10, 0, 10, 1, 3 },
{ 7, 2, 1, 9, 4 },
{ 0, 7, 5, 3, 5 },
{ 0, 3, 5, 3, 6 },
}
out.pack = pack
out.pokedex = self:pokedexGfx()
out.pokegear = self:pokegearGfx()
out.trainerCard = self:trainerCardGfx()
out.unownPuzzle = self:unownPuzzleGfx()
-- Emote bubbles (data/sprites/emotes.asm). Each is 4 tiles laid row-major
-- (TL, TR, BL, BR), unlike the pics, so they decode straight to a 16x16
-- sheet. They are OBJs, so colour 0 is transparent. Only the ones a
-- walkthrough reaches are pulled; showemote shows no bubble for the rest.
local emotes = {}
for key, label in pairs({
shock = "ShockEmote",
question = "QuestionEmote",
happy = "HappyEmote",
sad = "SadEmote",
}) do
local symbol = self.symbols[label]
if symbol then
local raw = self.rom:bytes(symbol[1], symbol[2], 4 * 16)
self:write2bpp(raw, 16, 16, "emotes/" .. key .. ".png", true)
emotes[key] = "assets/generated/emotes/" .. key .. ".png"
end
end
-- constants/script_constants.asm:188-195 EMOTE_* order, so showemote's byte
-- indexes straight into it.
emotes.order = { "shock", "question", "happy", "sad", "heart", "bolt",
"sleep", "fish" }
-- data/sprites/emotes.asm:22 `emote GrassRustleGFX, 1, $fe`: one tile, the
-- object ShakeGrass spawns (engine/overworld/map_objects.asm:2031).
local rustle = self.symbols["GrassRustleGFX"]
if rustle then
self:write2bpp(self.rom:bytes(rustle[1], rustle[2], 16),
8, 8, "emotes/grass_rustle.png", true)
emotes.grassRustle = "assets/generated/emotes/grass_rustle.png"
end
out.emotes = emotes
-- The Pokecenter heal machine's OBJ art (engine/events/heal_machine_anim
-- .asm .HealMachineGFX): two tiles side by side -- $7c the machine's light,
-- $7d the ball -- decoded as a 16x8 sheet with colour 0 transparent, the
-- way every OBJ sheet is. `.palettes` is gfx/overworld/heal_machine.pal,
-- the four colours .LoadPalettes copies over PAL_OW_TREE while the machine
-- runs; World's anim rotates them per flash the way .FlashPalettes does.
-- Both symbols are post-Phase-2 manifest additions, so a cache built from
-- an older manifest simply has no healMachine entry and the anim degrades
-- to its sounds.
local healGfx = self.symbols["HealMachineAnim.HealMachineGFX"]
if healGfx then
self:write2bpp(self.rom:bytes(healGfx[1], healGfx[2], 2 * 16),
16, 8, "emotes/heal_machine.png", true)
local healPal = self.symbols["HealMachineAnim.palettes"]
out.healMachine = {
sheet = "assets/generated/emotes/heal_machine.png",
palette = healPal and self:colors(healPal[1], healPal[2], 4) or nil,
}
end
-- The egg hatch cutscene's two assets (engine/pokemon/breeding.asm
-- EggHatch_AnimationSequence).
--
-- EggPic gfx/pokemon/egg/egg.2bpp.lz. EGG has no BaseData row, so
-- GetBaseData's `.egg` arm hardcodes `ld b, $55` -- 5 tiles
-- square (home/pokemon.asm:239-245) -- and GetFrontpic reads
-- the label directly instead of walking PokemonPicPointers.
-- A `--columns` pic like every other frontpic.
-- EggHatchGFX gfx/evo/egg_hatch.2bpp, TWO tiles copied to vTiles0 tile
-- $00 (breeding.asm:777): the crack and the shell fragment
-- the ten SPRITE_ANIM_OBJ_EGG_HATCH objects all draw from.
-- An OBJ sheet, so colour 0 is transparent.
--
-- Both are guarded: a cache built from an older manifest simply has no
-- eggHatch entry and src/ui/gen2/EggHatchAnim.lua runs its timing-only beat.
local eggHatch = {}
if self.symbols["EggPic"] then
local ok = pcall(function()
self:writeCompressedPic("EggPic", 5, "battle/front/egg.png")
end)
if ok then eggHatch.egg = "assets/generated/battle/front/egg.png" end
end
local hatchGfx = self.symbols["EggHatchGFX"]
if hatchGfx then
local symbol = self:symbol("EggHatchGFX")
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 2 * 16),
8, 16, "menu/egg_hatch.png", true)
eggHatch.shell = "assets/generated/menu/egg_hatch.png"
eggHatch.shellTiles = 2
end
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end
self:write("menu_gfx", out)
self:tick("Menu graphics", 1, 1)
return out
end
-- Unown puzzle art (engine/games/unown_puzzle.asm).
--
-- The four pictures are stored SMALL: each `*PuzzleLZ` decompresses to 36
-- tiles (48x48), and ConvertLoadedPuzzlePieces doubles them on the way into
-- VRAM. `.EnlargePuzzlePieceTiles` walks one source ROW at a time, first the
-- top four pixel rows of its six tiles and then the bottom four, splitting
-- each tile's high and low nibbles into two destination tiles -- which adds up
-- to a plain 2x nearest-neighbour scale laid out as a 12x12 tile sheet. The
-- scale is done here rather than at draw time so the border pass below, which
-- is a bitplane OR at the ENLARGED resolution, lands on the same bytes the
-- cart ORs.
--
-- .EnlargedTiles: each nibble bit becomes two bits, so $f -> $ff and $8 ->
-- $c0. Built rather than transcribed; the `for x, 16` in the asm builds it
-- the same way.
local ENLARGED_NIBBLE = {}
for value = 0, 15 do
ENLARGED_NIBBLE[value] = (value % 2) * 3
+ (math.floor(value / 2) % 2) * 0x0c
+ (math.floor(value / 4) % 2) * 0x30
+ (math.floor(value / 8) % 2) * 0xc0
end
local function orByte(a, b)
local out, bit = 0, 1
for _ = 1, 8 do
if (a % 2) == 1 or (b % 2) == 1 then out = out + bit end
a, b, bit = math.floor(a / 2), math.floor(b / 2), bit * 2
end
return out
end
-- UnownPuzzle_AddPuzzlePieceBorders' eight destination tiles: the outline is
-- drawn on every tile of a 3x3 piece EXCEPT its centre ($0d), and .LoadGFX
-- repeats each one across the 4x4 grid of pieces (+3 tiles per column, +36 per
-- row, since the sheet is 12 tiles wide).
local PUZZLE_BORDER_TILES = { 0x00, 0x01, 0x02, 0x0c, 0x0e, 0x18, 0x19, 0x1a }
function RomExtractorGen2:unownPuzzlePicture(label, relative)
local small = self:decompressLz3Symbol(label)
local out = {}
for row = 0, 5 do
for half = 0, 1 do
for col = 0, 5 do
local base = (row * 6 + col) * 16 + half * 8
for nibble = 0, 1 do
for line = 0, 3 do
local low = small[base + line * 2 + 1] or 0
local high = small[base + line * 2 + 2] or 0
if nibble == 0 then
low, high = math.floor(low / 16), math.floor(high / 16)
else
low, high = low % 16, high % 16
end
low, high = ENLARGED_NIBBLE[low], ENLARGED_NIBBLE[high]
out[#out + 1] = low
out[#out + 1] = high
out[#out + 1] = low
out[#out + 1] = high
end
end
end
end
end
local borders = self:symbol("PuzzlePieceBorderData.TileBordersGFX")
local raw = self.rom:bytes(borders.bank, borders.address, 8 * 16)
for index, target in ipairs(PUZZLE_BORDER_TILES) do
for pieceRow = 0, 3 do
for pieceCol = 0, 3 do
local tile = target + pieceCol * 3 + pieceRow * 36
for byte = 1, 16 do
local at = tile * 16 + byte
out[at] = orByte(out[at] or 0, raw[(index - 1) * 16 + byte] or 0)
end
end
end
end
self:write2bpp(out, 96, 96, relative)
return "assets/generated/" .. relative
end
function RomExtractorGen2:unownPuzzleGfx()
local puzzle = {}
-- .LZPointers, in UNOWNPUZZLE_* order (constants/script_constants.asm), so
-- the `setval` the chamber's bg_event runs indexes this list from 0.
puzzle.pictures = {}
for index, row in ipairs({
{ "KABUTO", "KabutoPuzzleLZ" },
{ "OMANYTE", "OmanytePuzzleLZ" },
{ "AERODACTYL", "AerodactylPuzzleLZ" },
{ "HO_OH", "HoOhPuzzleLZ" },
}) do
puzzle.pictures[index] = self:unownPuzzlePicture(row[2],
"menu/unown_puzzle/" .. row[1]:lower() .. ".png")
end
-- The picture is 4x4 pieces of 3x3 tiles; `.Corners` is the same arithmetic
-- written out, so a piece id (1..16) is a quad rather than a tile run.
puzzle.pieceTiles = 3
puzzle.piecesWide = 4
-- UnownPuzzleStartCancelLZ lands at vTiles0 tile $ed: $ee is PUZZLE_BORDER,
-- $ef PUZZLE_VOID, $f0..$f5 the START>CANCEL box corners and edges, and
-- $f6..$ff the ten tiles of the caption itself.
local chrome = self:decompressLz3Symbol("UnownPuzzleStartCancelLZ")
self:write2bpp(chrome, 152, 8, "menu/unown_puzzle/chrome.png")
puzzle.chrome = "assets/generated/menu/unown_puzzle/chrome.png"
puzzle.chromeFirstTile = 0xed
puzzle.chromeTiles = 19
-- UnownPuzzleCursorGFX is four OBJ tiles at vTiles0 $e0; the cursor's OAM
-- template mirrors them into a 3x3 bracket, so the sheet stays one row of
-- four rather than a 2x2 square.
local cursor = self:symbol("UnownPuzzleCursorGFX")
self:write2bpp(self.rom:bytes(cursor.bank, cursor.address, 4 * 16),
32, 8, "menu/unown_puzzle/cursor.png", true)
puzzle.cursor = "assets/generated/menu/unown_puzzle/cursor.png"
puzzle.cursorFirstTile = 0xe0
-- _CGB_UnownPuzzle (engine/gfx/cgb_layouts.asm:570) runs CopyFourPalettes
-- over PalPacket_UnownPuzzle, which is PREDEFPAL_UNOWN_PUZZLE four times, and
-- WipeAttrmap then puts every BG tile on palette 0: the whole board is
-- white / tan / dark brown / black. The `ld a, $e4` in _UnownPuzzle
-- (engine/games/unown_puzzle.asm:53) runs AFTER GetSGBLayout and is the
-- identity reorder of that palette, not a grey ramp.
puzzle.palette = self:predefPal(PREDEFPAL_UNOWN_PUZZLE)
-- The same palette goes into wOBPals1 with colour 0 overwritten by
-- `palred 31` (cgb_layouts.asm:577-581), and `ld a, $24 / call
-- DmgToCgbObjPal0` reorders OBJ pal 0 to entries 0, 1, 2, 0 -- so the cursor
-- bracket draws red.
local ob = self:predefPal(PREDEFPAL_UNOWN_PUZZLE)
ob[1] = { 255, 0, 0 }
puzzle.cursorPalette = { ob[1], ob[2], ob[3], ob[1] }
return puzzle
end
-- constants/scgb_constants.asm PREDEFPAL_*, for the two screens that colour
-- themselves out of the shared pool rather than an inline palette.
local PREDEFPAL_POKEDEX = 29
local PREDEFPAL_CGB_BADGE = 36
-- The GB tilemap, in tiles (SCREEN_WIDTH * SCREEN_HEIGHT).
local SCREEN_AREA = 20 * 18
-- #DEX chrome (engine/pokedex/pokedex.asm).
--
-- Pokedex_LoadGFX is four steps and only the last one is a sheet of its own:
-- the standard font goes to vTiles1 and is *inverted* (xor $ff flips both
-- bitplanes, so colour c becomes 3 - c), FontExtra lands at $60 and is
-- inverted too, and then PokedexLZ decompresses over vTiles2 tile $31 and
-- takes $31..$70 back off it. The inversion is why the dex prints white on
-- black: under PREDEFPAL_POKEDEX (white, orange, dark red, black) an inverted
-- glyph's ink is colour 0 and its cell is colour 3. Nothing here has to
-- invert anything -- the font pages are ink on transparent and the screen
-- draws them in white over the sheet's own dark tiles.
--
-- Tile ids the screens name, all relative to that one sheet:
-- $31 solid black, $32 the dark-red background ByteFill uses
-- $33..$3a the box frame (Pokedex_PlaceBorder: corners $33/$35/$38/$3a,
-- edges $34 top, $36 left, $37 right, $39 bottom)
-- $3b/$3c the ◀/▶ card arrows, $3d/$3e the ▲/▼ ones
-- $41..$4e the START/SEARCH/SELECT/OPTION word tiles, $4f the caught ball
-- $53/$54/$59/$5a/$5b the listing's vertical rule and its end caps
-- $55..$58 the dex entry's PAGE 1/2 marker, $5c/$5d "No.",
-- $5e ' and $5f ", $61 the horizontal divider, $62..$65 footprint
function RomExtractorGen2:pokedexGfx()
local dex = {}
local pixels = self:decompressLz3Symbol("PokedexLZ")
local tiles = math.floor(#pixels / 16)
local rows = math.ceil(tiles / 16)
while #pixels < rows * 16 * 16 do pixels[#pixels + 1] = 0 end
self:write2bpp(pixels, 128, rows * 8, "pokedex/dex.png")
dex.tiles = "assets/generated/pokedex/dex.png"
dex.tilesWide = 16
dex.firstTile = 0x31
dex.tileCount = tiles
-- The dex's OBJ sheet, at vTiles0. The search screen's Slowpoke is the
-- front of it; $30..$33 are the bracket the listing cursor is drawn from
-- (Pokedex_PutNewModeABCModeCursorOAM) and $0f the scrollbar thumb.
local objs = self:decompressLz3Symbol("PokedexSlowpokeLZ")
local objTiles = math.floor(#objs / 16)
local objRows = math.ceil(objTiles / 16)
while #objs < objRows * 16 * 16 do objs[#objs + 1] = 0 end
self:write2bpp(objs, 128, objRows * 8, "pokedex/objs.png", true)
dex.objs = "assets/generated/pokedex/objs.png"
dex.objsWide = 16
-- _CGB_Pokedex loads PREDEFPAL_POKEDEX as BG palette 0 for the whole
-- interface and the mon's own palette as 1 for the 7x7 frontpic;
-- _CGB_Pokedex_Resume puts PokedexCursorPalette in OBJ slot 7, which is the
-- palette every cursor sprite names.
dex.palette = self:predefPal(PREDEFPAL_POKEDEX)
local cursorPal = self:symbol("PokedexCursorPalette")
dex.cursorPalette = self:colors(cursorPal.bank, cursorPal.address, 4)
-- An unseen mon shows LoadQuestionMarkPic's 7x7 pic where the frontpic
-- goes, in its own green palette. It is a `--columns` pic like the mons'.
self:writeCompressedPic("LoadQuestionMarkPic.QuestionMarkLZ", 7,
"pokedex/question_mark.png")
dex.questionMark = "assets/generated/pokedex/question_mark.png"
local qmPal = self:symbol("PokedexQuestionMarkPalette")
dex.questionMarkPalette = self:colors(qmPal.bank, qmPal.address, 4)
-- Footprints (Pokedex_LoadAnyFootprint). Each is 16x16 in 1bpp, but not
-- contiguously: the table is 256-byte blocks of eight species, the eight
-- top halves first and the eight bottom halves 128 bytes later, which the
-- ASM's own comment blames on a mis-set tile editor. Written as one
-- 16-pixel-wide strip, two tile rows per species in speciesOrder.
local prints = self:symbol("Footprints")
local species = self.manifest.constants.speciesOrder or {}
local stream = {}
for index = 1, #species do
local base = math.floor((index - 1) / 8) * 256 + ((index - 1) % 8) * 16
for i = 0, 15 do
stream[#stream + 1] = self.rom:byte(prints.bank, prints.address + base + i)
end
for i = 0, 15 do
stream[#stream + 1] =
self.rom:byte(prints.bank, prints.address + base + 128 + i)
end
end
self:save(ImageWriter.decode1bpp(stream, 16, #species * 16),
"pokedex/footprints.png")
dex.footprints = "assets/generated/pokedex/footprints.png"
dex.footprintOrder = species
return dex
end
-- Reads a `tile, count` RLE tilemap (Pokegear_LoadTilemapRLE). The routine's
-- own comment says "repeat count, tile ID" and has it backwards: it loads b
-- from the first byte, c from the second, and writes `b` c times. $ff ends
-- the stream.
function RomExtractorGen2:readTilemapRLE(label, cells)
local symbol = self:symbol(label)
local out, offset = {}, 0
while #out < cells do
local tile = self.rom:byte(symbol.bank, symbol.address + offset)
if tile == 0xff then break end
local count = self.rom:byte(symbol.bank, symbol.address + offset + 1)
offset = offset + 2
for _ = 1, count do
if #out >= cells then break end
out[#out + 1] = tile
end
end
-- A stream that stops short leaves the screen holding whatever
-- InitPokegearTilemap put there, and that is `ld a, $4f / call ByteFill`
-- over the whole SCREEN_AREA (engine/pokegear/pokegear.asm:232-238), not a
-- blank. Only rows 12-17 are ever reached, which every card covers with its
-- bottom Textbox, so this is invisible until a card stops doing that.
while #out < cells do out[#out + 1] = 0x4f end
return out
end
-- Reads a flat $ff-terminated tilemap (FillTownMap).
function RomExtractorGen2:readFlatTilemap(label, cells)
local symbol = self:symbol(label)
local out = {}
for i = 0, cells - 1 do
local tile = self.rom:byte(symbol.bank, symbol.address + i)
if tile == 0xff then break end
out[i + 1] = tile
end
-- Same InitPokegearTilemap fill as readTilemapRLE above.
while #out < cells do out[#out + 1] = 0x4f end
return out
end
-- POKeGEAR (engine/pokegear/pokegear.asm).
--
-- Pokegear_LoadGFX puts TownMapGFX (48 tiles) at vTiles2 $00 and PokegearGFX
-- (48 tiles) at $30, so a card's tilemap addresses one 96-tile sheet and the
-- split at $30 is the only seam. Each card is a tilemap rather than a
-- layout: InitPokegearTilemap fills the screen with $4f, runs the card's
-- entry (an RLE tilemap for CLOCK/PHONE/RADIO, the painted region map for
-- MAP), then Pokegear_FinishTilemap lays the card icons across the top two
-- rows -- MAP at (2,0) from $40, PHONE at (4,0) from $44, RADIO at (6,0)
-- from $42, and the gear itself at (0,0) from $46, each a 2x2 written
-- $n, $n+1 / $n+2, $n+3.
--
-- Colour is by tile id, not by rectangle: TownMapPals walks the tilemap and
-- reads .PalMap, a nybble per tile for $00..$5f (low nybble even ids, high
-- odd), with $60 and up falling back to palette 0.
function RomExtractorGen2:pokegearGfx()
local gear = {}
local townMap = self:decompressLz3Symbol("TownMapGFX")
local gearTiles = self:decompressLz3Symbol("PokegearGFX")
-- One sheet, 16 tiles per row, so a tile id is (id % 16, id / 16) the same
-- way the intro's sheets read.
local sheet = {}
for i = 1, 0x30 * 16 do sheet[i] = townMap[i] or 0 end
for i = 1, 0x30 * 16 do sheet[0x30 * 16 + i] = gearTiles[i] or 0 end
self:write2bpp(sheet, 128, 48, "pokegear/gear.png")
gear.tiles = "assets/generated/pokegear/gear.png"
gear.tilesWide = 16
gear.townMapTiles = 0x30
local sprites = self:decompressLz3Symbol("PokegearSpritesGFX")
while #sprites < 10 * 16 do sprites[#sprites + 1] = 0 end
self:write2bpp(sprites, 16, 40, "pokegear/sprites.png", true)
gear.sprites = "assets/generated/pokegear/sprites.png"
gear.spritesWide = 2
local cells = SCREEN_AREA
gear.cards = {
clock = self:readTilemapRLE("ClockTilemapRLE", cells),
phone = self:readTilemapRLE("PhoneTilemapRLE", cells),
radio = self:readTilemapRLE("RadioTilemapRLE", cells),
}
gear.maps = {
johto = self:readFlatTilemap("JohtoMap", cells),
kanto = self:readFlatTilemap("KantoMap", cells),
}
local pals = self:symbol("PokegearPals")
gear.palettes = {}
for i = 0, 5 do
gear.palettes[i + 1] = self:colors(pals.bank, pals.address + i * 8, 4)
end
-- TownMapPals.PalMap: 48 bytes covering tiles $00..$5f, 1-based so the value
-- indexes gear.palettes directly.
local palMap = self:symbol("TownMapPals.PalMap")
gear.palMap = {}
for i = 0, 47 do
local byte = self.rom:byte(palMap.bank, palMap.address + i)
gear.palMap[i * 2 + 1] = byte % 8 + 1
gear.palMap[i * 2 + 2] = math.floor(byte / 16) % 8 + 1
end
return gear
end
-- Trainer card (engine/menus/trainer_card.asm).
--
-- ChrisPicAndTrainerCardGFX is two INCBINs run together -- chris_card (35
-- tiles, the 5x7 portrait) then trainer_card (6 tiles, the frame) -- copied
-- as one 41-tile block to vTiles2 $00, so the frame tiles start at $23:
-- $23 is the card's border cell, $24 and $04 the two notches
-- TrainerCard_InitBorder pokes into rows 7 and 1 of each box.
--
-- Page 1 then requests 86 tiles of CardStatusGFX at $29 and pages 2/3
-- request LeaderGFX at the same address; the badges themselves are OBJs out
-- of BadgeGFX with TrainerCard_JohtoBadgesOAM as their template table.
function RomExtractorGen2:trainerCardGfx()
local card = {}
local chris = self:symbol("ChrisPicAndTrainerCardGFX")
-- 41 tiles padded out to three 16-tile rows so the sheet is addressable as
-- (id % 16, id / 16) like every other one here.
local cardTiles = self.rom:bytes(chris.bank, chris.address, 41 * 16)
while #cardTiles < 48 * 16 do cardTiles[#cardTiles + 1] = 0 end
self:write2bpp(cardTiles, 128, 24, "trainer_card/card.png")
card.card = "assets/generated/trainer_card/card.png"
card.cardTilesWide = 16
card.portraitTiles = 35
card.portraitWide = 5
card.frameFirstTile = 0x23
local status = self:symbol("CardStatusGFX")
self:write2bpp(self.rom:bytes(status.bank, status.address, 6 * 16),
48, 8, "trainer_card/status.png")
card.status = "assets/generated/trainer_card/status.png"
card.statusWide = 6
card.statusFirstTile = 0x29
-- LeaderGFX is 86 tiles from $29: eight 10-tile gym-leader faces followed
-- by the five "BADGES" caption tiles at $79.
local leaders = self:symbol("LeaderGFX")
local leaderTiles = self.rom:bytes(leaders.bank, leaders.address, 86 * 16)
while #leaderTiles < 90 * 16 do leaderTiles[#leaderTiles + 1] = 0 end
self:write2bpp(leaderTiles, 80, 72, "trainer_card/leaders.png")
card.leaders = "assets/generated/trainer_card/leaders.png"
card.leadersWide = 10
card.leadersFirstTile = 0x29
local badges = self:symbol("BadgeGFX")
self:write2bpp(self.rom:bytes(badges.bank, badges.address, 44 * 16),
16, 176, "trainer_card/badges.png", true)
card.badges = "assets/generated/trainer_card/badges.png"
card.badgesWide = 2
-- TrainerCard_JohtoBadgesOAM: a wJohtoBadges pointer, then per badge
-- `y, x, palette` and two 4-byte animation cycles. y/x are OAM values, so
-- screen space is (x - 8, y - 16).
local oam = self:symbol("TrainerCard_JohtoBadgesOAM")
card.badgeOam = {}
for i = 0, 7 do
local at = oam.address + 2 + i * 11
local frames = {}
for f = 0, 7 do
frames[f + 1] = self.rom:byte(oam.bank, at + 3 + f)
end
card.badgeOam[i + 1] = {
y = self.rom:byte(oam.bank, at) - 16,
x = self.rom:byte(oam.bank, at + 1) - 8,
palette = self.rom:byte(oam.bank, at + 2),
frames = frames,
}
end
-- _CGB_TrainerCard loads nine palettes into an eight-palette table: BG 0 is
-- the player's own colours, BG 1-7 the first seven gym leaders', and the
-- ninth (PREDEFPAL_CGB_BADGE) runs off the end into OBJ palette 0, which is
-- exactly the palette every badge sprite names. The whole attrmap is
-- filled with palette 1 first, so the card frame -- and the eighth leader's
-- face, which gets no zone of its own -- wears Falkner's colours.
card.badgePalette = self:predefPal(PREDEFPAL_CGB_BADGE)
card.leaderClasses = { "FALKNER", "BUGSY", "WHITNEY", "MORTY", "CHUCK",
"JASMINE", "PRYCE" }
-- The FillBoxCGB zones, as {x, y, width, height, palette}, 1-based so the
-- palette indexes a Lua table directly.
card.paletteZones = {
{ 14, 1, 5, 7, 1 },
{ 18, 1, 1, 1, 2 },
{ 2, 11, 4, 2, 2 }, { 6, 11, 4, 2, 3 },
{ 10, 11, 4, 2, 4 }, { 14, 11, 4, 2, 5 },
{ 2, 14, 4, 2, 6 }, { 6, 14, 4, 2, 7 },
{ 10, 14, 4, 2, 8 },
}
return card
end
--------------------------------------------------------------------------
-- Battle animations (data/moves/animations.asm, data/battle_anims/*)
--------------------------------------------------------------------------
-- macros/scripts/battle_anims.asm. Anything under FIRST_BATTLE_ANIM_CMD
-- ($d0) is `anim_wait <n>` and carries no argument bytes; everything from
-- $d0 up is a command whose argument count is fixed. anim_obj's macro has
-- two spellings (a four-argument one and a legacy tile+offset one) but both
-- emit the same four bytes.
local ANIM_CMDS = {
[0xd0] = { "obj", 4 }, [0xd1] = { "1gfx", 1 }, [0xd2] = { "2gfx", 2 },
[0xd3] = { "3gfx", 3 }, [0xd4] = { "4gfx", 4 }, [0xd5] = { "5gfx", 5 },
[0xd6] = { "incobj", 1 }, [0xd7] = { "setobj", 2 },
[0xd8] = { "incbgeffect", 1 },
[0xd9] = { "battlergfx_2row", 0 }, [0xda] = { "battlergfx_1row", 0 },
[0xdb] = { "checkpokeball", 0 }, [0xdc] = { "transform", 0 },
[0xdd] = { "raisesub", 0 }, [0xde] = { "dropsub", 0 },
[0xdf] = { "resetobp0", 0 },
[0xe0] = { "sound", 2 }, [0xe1] = { "cry", 1 },
[0xe2] = { "minimizeopp", 0 }, [0xe3] = { "oamon", 0 },
[0xe4] = { "oamoff", 0 }, [0xe5] = { "clearobjs", 0 },
[0xe6] = { "beatup", 0 }, [0xe7] = { "unknown_e7", 0 },
[0xe8] = { "updateactorpic", 0 }, [0xe9] = { "minimize", 0 },
[0xea] = { "unknown_ea", 0 }, [0xeb] = { "unknown_eb", 0 },
[0xec] = { "unknown_ec", 0 }, [0xed] = { "unknown_ed", 0 },
[0xee] = { "if_param_and", 3 }, [0xef] = { "jumpuntil", 2 },
[0xf0] = { "bgeffect", 4 },
[0xf1] = { "bgp", 1 }, [0xf2] = { "obp0", 1 }, [0xf3] = { "obp1", 1 },
[0xf4] = { "keepsprites", 0 }, [0xf5] = { "unknown_f5", 0 },
[0xf6] = { "unknown_f6", 0 }, [0xf7] = { "unknown_f7", 0 },
[0xf8] = { "if_param_equal", 3 }, [0xf9] = { "setvar", 1 },
[0xfa] = { "incvar", 0 }, [0xfb] = { "if_var_equal", 3 },
[0xfc] = { "jump", 2 }, [0xfd] = { "loop", 3 },
[0xfe] = { "call", 2 }, [0xff] = { "ret", 0 },
}
-- The commands whose LAST two argument bytes are an address in the same
-- bank, which is what the disassembler follows to find sub-scripts.
local ANIM_BRANCHES = {
jumpuntil = 1, if_param_and = 2, if_param_equal = 2, if_var_equal = 2,
jump = 1, loop = 2, call = 1,
}
local NUM_BATTLE_ANIMS = 278 -- data/moves/animations.asm's table_width 2 rows
-- constants/move_constants.asm: the animation ids past the moves. The block
-- restarts at `const_next $ff`, so rows $fc-$fe of BattleAnimations are dead
-- padding and ANIM_SWEET_SCENT_2 is row $ff. These are the animations the
-- engine plays by id rather than by move -- the ball throw, the send-out, the
-- status loops, and the six "after" animations wBattleAfterAnim indexes.
local BATTLE_ANIM_IDS = {
[0xff] = "ANIM_SWEET_SCENT_2", [0x100] = "ANIM_THROW_POKE_BALL",
[0x101] = "ANIM_SEND_OUT_MON", [0x102] = "ANIM_RETURN_MON",
[0x103] = "ANIM_CONFUSED", [0x104] = "ANIM_SLP", [0x105] = "ANIM_BRN",
[0x106] = "ANIM_PSN", [0x107] = "ANIM_SAP", [0x108] = "ANIM_FRZ",
[0x109] = "ANIM_PAR", [0x10a] = "ANIM_IN_LOVE",
[0x10b] = "ANIM_IN_SANDSTORM", [0x10c] = "ANIM_IN_NIGHTMARE",
[0x10d] = "ANIM_IN_WHIRLPOOL", [0x10e] = "ANIM_MISS",
[0x10f] = "ANIM_ENEMY_DAMAGE", [0x110] = "ANIM_ENEMY_STAT_DOWN",
[0x111] = "ANIM_PLAYER_STAT_DOWN", [0x112] = "ANIM_PLAYER_DAMAGE",
[0x113] = "ANIM_WOBBLE", [0x114] = "ANIM_SHAKE",
[0x115] = "ANIM_HIT_CONFUSION",
}
-- Disassemble one animation script, following every branch target so a
-- sub-script that is only ever reached by anim_call ends up in the pool too.
-- Scripts are keyed by their ROM address because that is what a branch names;
-- the per-move table then points at one of those keys.
function RomExtractorGen2:readBattleAnimScript(bank, address, pool, order)
local key = ("%04x"):format(address)
if pool[key] then return key end
local rows = {}
pool[key] = rows -- claimed before the walk, so a self-jump terminates
order[#order + 1] = key
local at = address
local pending = {}
for _ = 1, 4096 do
local byte = self.rom:byte(bank, at)
at = at + 1
local spec = ANIM_CMDS[byte]
if not spec then
-- Under $d0 the byte IS the wait length.
rows[#rows + 1] = { "wait", byte }
else
local row = { spec[1] }
for i = 1, spec[2] do
row[i + 1] = self.rom:byte(bank, at)
at = at + 1
end
local branch = ANIM_BRANCHES[spec[1]]
if branch then
-- The address is the last two bytes, little-endian.
local low, high = row[branch + 1], row[branch + 2]
local target = low + high * 256
row[branch + 1] = ("%04x"):format(target)
row[branch + 2] = nil
pending[#pending + 1] = target
end
rows[#rows + 1] = row
if spec[1] == "ret" then break end
-- An unconditional jump ends this run; its target is walked below.
if spec[1] == "jump" then break end
end
end
for _, target in ipairs(pending) do
self:readBattleAnimScript(bank, target, pool, order)
end
return key
end
-- BattleAnimObjects: seven bytes an entry (battleanimobj), naming the
-- frameset it plays, the per-frame function that moves it, its palette and
-- which GFX sheet its tiles come from.
function RomExtractorGen2:readBattleAnimObjects()
local consts = self.manifest.constants or {}
local names = consts.battleAnimObjectOrder or {}
local framesets = consts.battleAnimFramesetOrder or {}
local funcs = consts.battleAnimFuncOrder or {}
-- An animation object is an OBJ, so its palette byte names an OBJ palette:
-- PAL_BATTLE_OB_*, which is its own const block starting at zero, not the
-- BG one beside it in the same file.
local pals = consts.battleAnimObPaletteOrder or {}
local gfx = consts.battleAnimGfxOrder or {}
local symbol = self:symbol("BattleAnimObjects")
local out = {}
for index, name in ipairs(names) do
-- BATTLEANIMOBJ_LENGTH is `_RS - 1` -- the struct's INDEX byte is runtime
-- state and is NOT in the table -- so a row is SIX bytes, not seven.
local row = self.rom:bytes(symbol.bank, symbol.address + (index - 1) * 6, 6)
out[name] = {
-- bit 0 is "fix the enemy's coordinates"; bits 5-7 are flip/priority.
flags = row[1],
fixY = row[2],
frameset = framesets[row[3] + 1] or row[3],
func = funcs[row[4] + 1] or row[4],
palette = pals[row[5] + 1] or row[5],
-- battleAnimGfxOrder carries a placeholder for AnimObjGFX's empty row 0
-- (the const block itself starts at 1), so value + 1 indexes it like
-- every other list here.
gfx = gfx[row[6] + 1] or row[6],
tileOffset = row[7],
}
end
return out
end
-- BattleAnimFrameData: a pointer table of oamframe lists, in the SAME format
-- the overworld sprite anims use -- which is the point. src/ui/gen2/
-- SpriteAnims.lua already runs these, so the runtime is shared rather than
-- written twice.
function RomExtractorGen2:readBattleAnimFramesets()
local names = (self.manifest.constants or {}).battleAnimFramesetOrder or {}
local oamsets = (self.manifest.constants or {}).battleAnimOamsetOrder or {}
local symbol = self:symbol("BattleAnimFrameData")
local out = {}
for index, name in ipairs(names) do
local low = self.rom:byte(symbol.bank, symbol.address + (index - 1) * 2)
local high = self.rom:byte(symbol.bank, symbol.address + (index - 1) * 2 + 1)
local at = low + high * 256
local frames = {}
-- macros/scripts/oam_anims.asm counts DOWN from $ff: oamend $ff,
-- oamrestart $fe, oamwait $fd, oamdelete $fc. Everything below is an OAM
-- set id, and with 216 of them there is no collision.
for _ = 1, 64 do
local byte = self.rom:byte(symbol.bank, at)
at = at + 1
if byte == 0xff then
frames[#frames + 1] = { "end" }
break
elseif byte == 0xfe then
frames[#frames + 1] = { "restart" }
break
elseif byte == 0xfd then
frames[#frames + 1] = { "wait", self.rom:byte(symbol.bank, at) }
at = at + 1
elseif byte == 0xfc then
frames[#frames + 1] = { "delete" }
break
else
-- oamframe's OWN comment calls its first byte the duration and is
-- wrong: `oamframe BATTLE_ANIM_OAMSET_00, 6` passes the OAM SET as
-- \1, so the set comes first and the duration second. The duration's
-- top two bits are the flip arguments, one bit above the OAM flags
-- they become -- GetSpriteAnimFrame shifts them back down.
local duration = self.rom:byte(symbol.bank, at)
at = at + 1
frames[#frames + 1] = {
"frame", oamsets[byte + 1] or byte,
bit.band(duration, 0x3f),
bit.rshift(bit.band(duration, 0xc0), 1),
}
end
end
out[name] = frames
end
return out
end
-- BattleAnimOAMData: `battleanimoam <vtile offset>, <length>, <data>` and
-- then a run of dbsprite rows.
function RomExtractorGen2:readBattleAnimOamsets()
local names = (self.manifest.constants or {}).battleAnimOamsetOrder or {}
local symbol = self:symbol("BattleAnimOAMData")
local out = {}
for index, name in ipairs(names) do
local row = self.rom:bytes(symbol.bank, symbol.address + (index - 1) * 4, 4)
local at = row[3] + row[4] * 256
local sprites = {}
for i = 1, row[2] do
-- dbsprite is four bytes: y, x, vtile offset, attributes -- and the y
-- byte comes FIRST even though the macro's arguments read x first.
local entry = self.rom:bytes(symbol.bank, at + (i - 1) * 4, 4)
sprites[i] = {
y = entry[1], x = entry[2], tile = entry[3], attr = entry[4],
}
end
out[name] = { vtile = row[1], sprites = sprites }
end
return out
end
-- AnimObjGFX: `anim_obj_gfx <tiles>, <label>` -- a count and a three-byte
-- far pointer at a compressed sheet.
function RomExtractorGen2:readBattleAnimGfx()
local names = (self.manifest.constants or {}).battleAnimGfxOrder or {}
local symbol = self:symbol("AnimObjGFX")
local out = {}
for index, name in ipairs(names) do
local row = self.rom:bytes(symbol.bank, symbol.address + (index - 1) * 4, 4)
local tiles = row[1]
if tiles > 0 then
local bank, address = row[2], row[3] + row[4] * 256
local ok, pixels = pcall(function()
local compressed = self.rom:bytes(bank, address, 0x8000 - address)
return Rom.decompressLz3(compressed)
end)
if ok and pixels then
local wide = math.min(tiles, 8)
local high = math.ceil(tiles / wide)
local need = wide * high * 16
while #pixels < need do pixels[#pixels + 1] = 0 end
while #pixels > need do table.remove(pixels) end
local rel = ("battle_anims/%s.png"):format(name:lower())
self:write2bpp(pixels, wide * 8, high * 8, rel, true)
out[name] = {
tiles = tiles, wide = wide,
image = "assets/generated/" .. rel,
}
end
end
end
return out
end
function RomExtractorGen2:extractBattleAnims()
self:beginStage("Battle animations")
local moveOrder = (self.manifest.constants or {}).moveOrder or {}
local table_ = self:symbol("BattleAnimations")
local pool, order = {}, {}
local byMove, byId = {}, {}
for index = 1, NUM_BATTLE_ANIMS do
local base = table_.address + (index - 1) * 2
local low = self.rom:byte(table_.bank, base)
local high = self.rom:byte(table_.bank, base + 1)
local address = low + high * 256
-- Row 0 is BattleAnim_Dummy; rows 1..NUM_MOVES are the moves in order,
-- and the rows past them are the shared non-move animations (status,
-- stat changes, the ball throw) the engine plays by id.
local key = self:readBattleAnimScript(table_.bank, address, pool, order)
local name = (index > 1) and moveOrder[index - 1] or nil
if name then byMove[name] = key end
local idName = BATTLE_ANIM_IDS[index - 1]
if idName then byId[idName] = key end
if index % 40 == 0 then
self:tick("Battle animations", index, NUM_BATTLE_ANIMS)
end
end
local scripts = {}
for _, key in ipairs(order) do scripts[key] = pool[key] end
local data = {
generation = 2,
source = "ROM:BattleAnimations + data/battle_anims/*",
bank = table_.bank,
-- Every script in one pool keyed by its ROM address; `moves` and `ids`
-- point into it. A sub-script reached only by anim_call has no name of
-- its own, which is why the pool is addressed rather than named.
scripts = scripts,
scriptOrder = order,
moves = byMove,
-- The animations the engine plays by id: the ball throw, the send-out
-- slide, the status loops and the six wBattleAfterAnim entries.
ids = byId,
objects = self:readBattleAnimObjects(),
framesets = self:readBattleAnimFramesets(),
oamsets = self:readBattleAnimOamsets(),
gfx = self:readBattleAnimGfx(),
}
self:write("battle_anims", data)
self:tick("Battle animations", NUM_BATTLE_ANIMS, NUM_BATTLE_ANIMS)
return data
end
-- The Magnet Train's two tilemaps (engine/events/magnet_train.asm).
--
-- MagnetTrainBGTiles is a 2x18 strip: two tile ids per screen row, which
-- DrawMagnetTrain's .FillAlt repeats across all TILEMAP_WIDTH / 2 column pairs
-- to build the scrolling scenery. MagnetTrainTilemap is the 20x4 train itself,
-- laid over BG rows 6-9 on top of it.
--
-- Neither is compressed and neither is pointed at by anything the script
-- walker follows, so both are read straight off their labels. No tile GRAPHICS
-- come with them: MagnetTrain_LoadGFX_PlayMusic loads none, and every id above
-- indexes whatever the station's own tileset (TILESET_TRAIN_STATION) already
-- had in VRAM.
local MAGNET_TRAIN_BG_ROWS = 18 -- SCREEN_HEIGHT
local MAGNET_TRAIN_FG_WIDTH = 20 -- SCREEN_WIDTH
local MAGNET_TRAIN_FG_ROWS = 4
function RomExtractorGen2:readMagnetTrain()
-- A manifest generated before these two labels were required carries neither,
-- and an import that hard-errored on that would take the whole cache build
-- down over a cutscene; the ride runs without the art.
if not (self.symbols["MagnetTrainBGTiles"]
and self.symbols["MagnetTrainTilemap"]) then
return nil
end
local bgSymbol = self:symbol("MagnetTrainBGTiles")
local bgTiles = {}
for index = 0, MAGNET_TRAIN_BG_ROWS * 2 - 1 do
bgTiles[index + 1] = self.rom:byte(bgSymbol.bank, bgSymbol.address + index)
end
local fgSymbol = self:symbol("MagnetTrainTilemap")
local tilemap = {}
for index = 0, MAGNET_TRAIN_FG_WIDTH * MAGNET_TRAIN_FG_ROWS - 1 do
tilemap[index + 1] = self.rom:byte(fgSymbol.bank, fgSymbol.address + index)
end
return {
source = "ROM:MagnetTrainBGTiles + MagnetTrainTilemap",
bgTiles = bgTiles,
tilemap = tilemap,
width = MAGNET_TRAIN_FG_WIDTH,
rows = MAGNET_TRAIN_FG_ROWS,
}
end
-- The field table is mostly still a stub; `extras` is what a table has already
-- grown real contents in, merged over the stub note so the rest of it stays
-- honestly marked as unextracted.
function RomExtractorGen2:extractStubs()
self:beginStage("Remaining data (stubs)")
local names = {
"field",
}
local extras = {
field = { magnetTrain = self:readMagnetTrain() },
}
for index, name in ipairs(names) do
local data = {
generation = 2,
source = "Gold Phase 2 stub -- not yet extracted, see docs/gold-phase1.md",
}
for key, value in pairs(extras[name] or {}) do data[key] = value end
self:write(name, data)
self:tick("Remaining data (stubs)", index, #names)
end
return true
end
function RomExtractorGen2:run()
local results = {}
results.constants = self:extractConstants()
results.font = self:extractFont()
results.palettes = self:extractPalettes()
results.tilesets = self:extractTilesets()
results.maps = self:extractMaps()
results.sprites = self:extractSprites()
results.stdScripts = self:extractStdScripts()
results.scripts = self:extractScriptsAndText(results.maps, results.stdScripts)
results.pokemon = self:extractPokemon()
results.moves = self:extractMoves()
results.items = self:extractItems()
results.marts = self:extractMarts()
results.encounters = self:extractEncounters()
results.trainers = self:extractTrainers()
results.pokedex = self:extractPokedex()
results.landmarks = self:extractLandmarks()
results.icons = self:extractIcons()
results.intro = self:extractIntro()
results.menuGfx = self:extractMenuGfx()
results.oakSpeech = self:extractOakSpeech(results.pokemon)
results.title = self:extractTitle()
results.credits = self:extractCredits()
results.diploma = self:extractDiploma()
results.trade = self:extractTrade()
results.audio = self:extractAudio(results.maps)
results.battleAnims = self:extractBattleAnims()
results.stubs = self:extractStubs()
if self.progress then
self.progress(STAGE_COUNT, STAGE_COUNT, "Ready", 1, 1)
end
return results
end
return RomExtractorGen2