mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-23 14:07:34 +02:00
e094b73536
Gold and Silver had no label-keyed string table at all. The manifests carried no text section, RomExtractorGen2 had no extractText, and game.data.text was never assigned, so every call through src/core/RomText.lua fell back to the literal written beside it. The only Gen 2 text the cache held was the script text in data/generated/text.lua, keyed by bank:address for the overworld VM, which nothing can look a battle line up in. make_gold_manifest.py now walks data/text/'s five dialogue files for their labels, the way make_rom_manifest.text_metadata walks pokered's, and embeds each one's symbol. 889 labels, all of them resolving in both editions. make_silver_manifest.py inherits the list unchanged and re-resolves the addresses from pokesilver.sym. RomExtractorGen2:extractText decodes them into data/generated/rom_text.lua. The mechanism is the one extractOakSpeech already used for _OakText1-7: resolve the label, decode from the cart, key by name. What is new is that the list comes from the manifest rather than being written out in Lua, so all of data/text/ arrives instead of seven strings. decodeGen2Text also emits the three runtime name slots it used to drop. PlaceMoveUsersName, PlaceMoveTargetsName and PlaceEnemysName (home/text.asm) substitute a battler's name as the line prints, so <USER>, <TARGET> and <ENEMY> are markers, not glyphs. Skipped as control glyphs, SubTookDamageText decoded as "The SUBSTITUTE / took damage for" with nothing after it. Game2:load assigns the table to self.data.text, which is what makes the existing shared RomText helper work on Gold and Silver at all. The new cache file is listed in the Gold override rather than bumping CACHE_FORMAT, so caches built before this stage re-import themselves and Red, Blue and Yellow are left alone.
6358 lines
266 KiB
Lua
6358 lines
266 KiB
Lua
-- 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 GameVersion = require("src.core.GameVersion")
|
||
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 = 27
|
||
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
|
||
-- LoadBallIconGFX.gfx (engine/battle/trainer_huds.asm:225-232); bank $0b
|
||
-- carries no manifest symbol to resolve it through.
|
||
local BALL_ICON_GFX = { 0x0b, 0x41a4 }
|
||
-- 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,
|
||
}
|
||
|
||
-- The three runtime name slots. PlaceMoveUsersName, PlaceMoveTargetsName and
|
||
-- PlaceEnemysName (home/text.asm:302, :307, :327) swap these for a battler's
|
||
-- own name as the line prints, so they are markers rather than glyphs. They
|
||
-- decode to the same shape Gen 1 uses, which src/core/RomText.lua already
|
||
-- fills in argument order. Dropped, SubTookDamageText read "took damage
|
||
-- for" with nothing after it.
|
||
local NAME_SLOT = {
|
||
["<USER>"] = "{USER}",
|
||
["<TARGET>"] = "{TARGET}",
|
||
["<ENEMY>"] = "{ENEMY}",
|
||
}
|
||
|
||
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,
|
||
-- _GOLD / _SILVER: the labels are shared, the data behind a handful of
|
||
-- them is not (gfx/misc.asm:9-20 vs :46-57).
|
||
edition = GameVersion.forSha1(manifest.romSha1) == "silver"
|
||
and "silver" or "gold",
|
||
}, RomExtractorGen2)
|
||
end
|
||
|
||
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
|
||
|
||
local silver = self.edition == "silver"
|
||
|
||
-- pret gfx/title/title_bg_gold.pal / title_bg_silver.pal (5 BG pals);
|
||
-- GSTitleBGPals is the edition-selected include (engine/gfx/color.asm:1234).
|
||
local BG_PALS = silver and {
|
||
{ { 31, 31, 31 }, { 0, 12, 15 }, { 4, 8, 21 }, { 0, 0, 0 } },
|
||
{ { 31, 21, 0 }, { 15, 17, 15 }, { 4, 8, 21 }, { 0, 0, 17 } },
|
||
{ { 31, 31, 31 }, { 31, 0, 0 }, { 4, 8, 21 }, { 0, 0, 0 } },
|
||
{ { 31, 31, 31 }, { 24, 23, 25 }, { 4, 8, 21 }, { 8, 8, 9 } },
|
||
{ { 31, 31, 31 }, { 5, 10, 11 }, { 0, 12, 15 }, { 0, 0, 0 } },
|
||
} or {
|
||
{ { 31, 31, 31 }, { 18, 23, 31 }, { 15, 20, 31 }, { 0, 0, 0 } },
|
||
{ { 31, 21, 0 }, { 12, 14, 12 }, { 15, 20, 31 }, { 0, 0, 17 } },
|
||
{ { 31, 31, 31 }, { 31, 0, 0 }, { 15, 20, 31 }, { 0, 0, 0 } },
|
||
{ { 31, 31, 31 }, { 29, 25, 0 }, { 15, 20, 31 }, { 17, 10, 1 } },
|
||
{ { 31, 31, 31 }, { 23, 26, 31 }, { 18, 23, 31 }, { 0, 0, 0 } },
|
||
}
|
||
-- title_fg.pal, shared (GSTitleOBPals, engine/gfx/color.asm:1241): pal 0 =
|
||
-- Ho-Oh silhouette; pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail).
|
||
local OBJ_HOOH = {
|
||
{ 31, 31, 31 }, { 7, 6, 3 }, { 7, 6, 3 }, { 7, 6, 3 },
|
||
}
|
||
local OBJ_TRAIL = {
|
||
{ 31, 31, 31 }, { 31, 31, 0 }, { 26, 22, 0 }, { 0, 0, 0 },
|
||
}
|
||
-- engine/movie/title.asm:134-141 + CopyPals (home/palettes.asm:190):
|
||
-- DmgToCgbObjPal0 %11100000 makes Silver's OBJ pal 0 {c0, c0, c2, c3}.
|
||
if silver then
|
||
OBJ_HOOH = {
|
||
OBJ_HOOH[1], OBJ_HOOH[1], OBJ_HOOH[3], OBJ_HOOH[4],
|
||
}
|
||
-- .OAMData_GSTitleTrail is attribute 0, not OAM_PAL1 (oam.asm:834-837).
|
||
OBJ_TRAIL = OBJ_HOOH
|
||
end
|
||
|
||
local function palColor(pal, shade)
|
||
local c = pal[shade + 1] or pal[4]
|
||
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 }
|
||
-- engine/movie/title.asm:105-115: Silver writes %11110000 to both OBPs.
|
||
local DMG_OBP0 = silver and { 0, 0, 3, 3 } or { 3, 3, 3, 3 }
|
||
local DMG_OBP1 = silver and { 0, 0, 3, 3 } or { 0, 2, 3, 3 }
|
||
-- ImageWriter's four hardware shades, by shade number.
|
||
local DMG_SHADE = { 1, 2 / 3, 1 / 3, 0 }
|
||
|
||
-- 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)
|
||
-- .OAMData_GSIntroLugia1 / 2 (data/sprite_anims/oam.asm:736-773); the
|
||
-- spriteanimoam vtile offset is added per frame (core.asm:224-227).
|
||
local LUGIA_1 = {
|
||
{ -5, -2, 0, 0, 0x00 }, { -5, 0, 0, 0, 0x02 },
|
||
{ -4, -2, 0, 0, 0x04 }, { -4, 0, 0, 0, 0x06 },
|
||
{ -3, -1, 0, 0, 0x08 }, { -2, -1, 0, 0, 0x0a },
|
||
{ -1, -2, 0, 0, 0x0c }, { -1, 0, 0, 0, 0x0e },
|
||
{ 0, -2, 0, 0, 0x10 }, { 0, 0, 0, 0, 0x12 },
|
||
{ 1, -2, 0, 0, 0x14 }, { 1, 0, 0, 0, 0x16 },
|
||
{ 2, -2, 0, 0, 0x18 }, { 2, 0, 0, 0, 0x1a },
|
||
{ 3, -1, 0, 0, 0x1c }, { 4, -1, 0, 0, 0x1e },
|
||
}
|
||
local LUGIA_2 = {
|
||
{ -5, -2, 0, 0, 0x00 }, { -5, 0, 0, 0, 0x02 },
|
||
{ -4, -2, 0, 0, 0x04 }, { -4, 0, 0, 0, 0x06 },
|
||
{ -3, -1, 0, 0, 0x08 }, { -2, -1, 0, 0, 0x0a },
|
||
{ -1, -2, 0, 0, 0x0c }, { -1, 0, 0, 0, 0x0e },
|
||
{ 0, -2, 0, 0, 0x10 }, { 0, 0, 0, 0, 0x12 },
|
||
{ 1, -2, 0, 0, 0x14 }, { 1, 0, 0, 0, 0x16 },
|
||
{ 2, -2, 0, 0, 0x18 }, { 2, 0, 0, 0, 0x1a },
|
||
{ 3, -2, 0, 0, 0x1c }, { 4, -2, 0, 0, 0x1e },
|
||
}
|
||
local HOOH_FRAMES = {
|
||
{ -- 1
|
||
{ -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 },
|
||
{ -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 },
|
||
},
|
||
}
|
||
-- Silver's five oamsets, as {layout, vtile base} (oam.asm:103-107).
|
||
local LUGIA_FRAMES = {
|
||
{ LUGIA_1, 0x00 }, { LUGIA_1, 0x20 }, { LUGIA_2, 0x40 },
|
||
{ LUGIA_2, 0x60 }, { LUGIA_1, 0x00 },
|
||
}
|
||
-- Frameset_GSIntroHoOhLugia (data/sprite_anims/framesets.asm:376-396):
|
||
-- Gold 1,2,3,4,3,5; Silver 2,1,2,3,3,4,4,3,2 on a faster clock.
|
||
local HOOH_SEQUENCE = silver and {
|
||
{ 2, 3 }, { 1, 7 }, { 2, 7 }, { 3, 7 }, { 3, 7 },
|
||
{ 4, 7 }, { 4, 7 }, { 3, 7 }, { 2, 3 },
|
||
} or {
|
||
{ 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 },
|
||
}
|
||
local hoohPaths, hoohGrayPaths = {}, {}
|
||
-- Lugia1/2 span x tiles -5..4, four tiles wider than Ho-Oh's -4..3.
|
||
local originX, originY = silver and 40 or 32, 24
|
||
local poseW = silver and 80 or 64
|
||
local frames = silver and LUGIA_FRAMES or HOOH_FRAMES
|
||
for fi, entry in ipairs(frames) do
|
||
local oam = silver and entry[1] or entry
|
||
local base = silver and entry[2] or 0
|
||
-- The pose starts EMPTY, not white: an OBJ's colour 0 is transparent
|
||
-- wherever it falls, so a gap enclosed by the bird shows the sky through
|
||
-- exactly like one outside it, and there is no matte to flood-fill.
|
||
local pose = ImageWriter.blank(poseW, 64, 0, 0, 0, 0)
|
||
for _, spr in ipairs(oam) do
|
||
local px = originX + spr[1] * 8 + spr[3]
|
||
local py = originY + spr[2] * 8 + spr[4]
|
||
blitSprite(pose, hoohTiles[base + spr[5] + 1], px, py)
|
||
blitSprite(pose, hoohTiles[base + spr[5] + 2], px, py + 8)
|
||
end
|
||
local tinted = colorize(pose, function() return OBJ_HOOH end)
|
||
local rel = ("title/hooh_%d.png"):format(fi)
|
||
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; Gold's OAM is one 8x16, Silver's two
|
||
-- side by side, and only 4 of Silver's 8 copied tiles exist (title.asm:43).
|
||
local trailSym = self:symbol("TitleScreenGFX3")
|
||
local trailTileCount = silver and 4 or 8
|
||
local trailRaw =
|
||
self.rom:bytes(trailSym.bank, trailSym.address, trailTileCount * 16)
|
||
local trailTiles = tilesFrom2bpp(trailRaw, true)
|
||
local trail = ImageWriter.blank(silver and 16 or 8, 16, 0, 0, 0, 0)
|
||
blitSprite(trail, trailTiles[1], 0, 0)
|
||
blitSprite(trail, trailTiles[2], 0, 8)
|
||
if silver then
|
||
blitSprite(trail, trailTiles[3], 8, 0)
|
||
blitSprite(trail, trailTiles[4], 8, 8)
|
||
end
|
||
local trailTint = colorize(trail, function() return OBJ_TRAIL end)
|
||
self:save(trailTint, "title/trail.png")
|
||
self:save(throughRegister(trail, DMG_OBP1), "title/trail_gray.png")
|
||
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, frame index 1-based + duration frames.
|
||
hoohSequence = HOOH_SEQUENCE,
|
||
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
|
||
hoohBobAmplitude = silver and 8 or 2,
|
||
hoohBobStep = silver and -1 or 1,
|
||
-- `depixel 12, 11` (engine/movie/title.asm). Two traps, and the port had
|
||
-- fallen into both, which is what put Ho-Oh off-centre:
|
||
-- * ldpixel's own comment calls its first tile argument the X one and is
|
||
-- 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, so the sheet's corner is
|
||
-- (88 - 8 - originX, 96 - 16 - originY) -- and the pose's width then lands
|
||
-- centred on the screen (Ho-Oh 48..112, Lugia 40..120).
|
||
hoohX = 80 - originX,
|
||
hoohY = 80 - originY,
|
||
trail = "assets/generated/title/trail.png",
|
||
copyright = "assets/generated/title/copyright.png",
|
||
copyrightSplash = "assets/generated/title/copyright_splash.png",
|
||
-- ScrollTitleScreenClouds (engine/menus/intro_menu.asm:917-928): Gold
|
||
-- decrements the cloud-band SCX every 8 vblanks, so the strip slides 1px
|
||
-- right. Silver does the same decrement every frame.
|
||
cloudScrollEvery = silver and 1 or 8,
|
||
cloudY = 88,
|
||
-- BG pal 0 colour 2: the sky the widescreen bands have to match.
|
||
sky = {
|
||
BG_PALS[1][3][1] / 31, BG_PALS[1][3][2] / 31, BG_PALS[1][3][3] / 31,
|
||
},
|
||
-- The fill under the cloud/wave band: Gold's cloud field is BG pal 0
|
||
-- colour 0 (white), Silver's sea floor is colour 3 (black).
|
||
below = silver and {
|
||
BG_PALS[1][4][1] / 31, BG_PALS[1][4][2] / 31, BG_PALS[1][4][3] / 31,
|
||
} or {
|
||
BG_PALS[1][1][1] / 31, BG_PALS[1][1][2] / 31, BG_PALS[1][1][3] / 31,
|
||
},
|
||
-- UpdateTitleTrailSprite (engine/menus/intro_menu.asm:1069-1124). Silver's
|
||
-- `depixel 15, 11, 4, 0` is OAM (88, 124), less the bias and the (-16, -8)
|
||
-- corner .OAMData_GSTitleTrail draws from.
|
||
trailMode = silver and "silver" or "gold",
|
||
trailSpawns = silver and { { 72, 100 } } or {
|
||
{ 80, 88 }, { 104, 88 }, { 104, 88 }, { 120, 88 },
|
||
{ 120, 88 }, { 88, 88 },
|
||
},
|
||
trailSpawnEvery = 4,
|
||
trailStepX = 4,
|
||
trailStepY = silver and 0 or 1,
|
||
-- AnimSeq_GSTitleTrail (functions.asm:784-813) with wIntroSceneTimer 0.
|
||
trailBobAmplitude = silver and 3 or 2,
|
||
trailPhaseStep = silver and 7 or 3,
|
||
trailPhase = silver and 0 or nil,
|
||
}
|
||
self:write("title", data)
|
||
return data
|
||
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 NAME_SLOT[ch] then
|
||
out[#out + 1] = NAME_SLOT[ch]
|
||
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]
|
||
-- Script_givepoke (engine/overworld/scripting.asm:1806)
|
||
if size == 8 then
|
||
local function readAt(lo, hi)
|
||
local addr = (args[lo] or 0) + (args[hi] or 0) * 0x100
|
||
if not romAddrOk(bank, addr) then return nil end
|
||
local okStr, str = pcall(self.rom.readString, self.rom,
|
||
bank, addr, charmap, 0x50, 16)
|
||
return okStr and str or nil
|
||
end
|
||
cmd.name = readAt(5, 6)
|
||
cmd.otName = readAt(7, 8)
|
||
end
|
||
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
|
||
|
||
-- The engine's own strings, keyed by the label the disassembly gives them.
|
||
--
|
||
-- This is what extractOakSpeech has always done for _OakText1-7: resolve the
|
||
-- label, decode from the cart, key by name. What is new is that the list of
|
||
-- labels comes from the manifest instead of being written out here, so all of
|
||
-- data/text/ arrives rather than seven strings. Gen 1 has had the same table
|
||
-- since RomExtractor:extractText; this is the Gen 2 side of it, and it is
|
||
-- what lets src/core/RomText.lua work on Gold and Silver at all.
|
||
--
|
||
-- Written as `rom_text` rather than `text`: data/generated/text.lua is
|
||
-- already the script text, keyed by bank:address for the overworld VM, and
|
||
-- these are a different table with different keys.
|
||
function RomExtractorGen2:extractText()
|
||
self:beginStage("Dialogue")
|
||
local charmap = self.manifest.charmap or {}
|
||
local labels = (self.manifest.text or {}).labels or {}
|
||
local texts = {}
|
||
for index, label in ipairs(labels) do
|
||
local location = self.symbols[label]
|
||
-- A label the manifest names but the symbol table does not carry would
|
||
-- be a generator bug, not a cart difference: make_gold_manifest.py
|
||
-- resolves every one of these before it writes the list.
|
||
if location then
|
||
texts[label] = self:decodeGen2Text(location[1], location[2], charmap)
|
||
end
|
||
self:tick("Dialogue", index, #labels)
|
||
end
|
||
self:write("rom_text", texts)
|
||
return texts
|
||
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%.
|
||
-- Rows with species == 0 (time_group in pokegold data/wild/fish.asm) index
|
||
-- TimeFishGroups [day_species, day_level, nite_species, nite_level].
|
||
self:trace("fish groups")
|
||
local fish = self:symbol("FishGroups")
|
||
local timeFishSym = self.symbols.TimeFishGroups and self:symbol("TimeFishGroups")
|
||
local timeFishBank = timeFishSym and timeFishSym.bank or (fish and fish.bank)
|
||
local timeFishAddr = timeFishSym and timeFishSym.address or (fish and 0x6BDE)
|
||
local timeFishGroups = {}
|
||
if timeFishBank and timeFishAddr then
|
||
for idx = 0, 31 do
|
||
local base = timeFishAddr + idx * 4
|
||
if not romAddrOk(timeFishBank, base + 3) then break end
|
||
local daySp = self.rom:byte(timeFishBank, base)
|
||
local dayLv = self.rom:byte(timeFishBank, base + 1)
|
||
local niteSp = self.rom:byte(timeFishBank, base + 2)
|
||
local niteLv = self.rom:byte(timeFishBank, base + 3)
|
||
if daySp == 0 or daySp > 251 or niteSp == 0 or niteSp > 251 then break end
|
||
timeFishGroups[idx] = {
|
||
day = { species = self:speciesName(daySp), level = dayLv },
|
||
nite = { species = self:speciesName(niteSp), level = niteLv },
|
||
}
|
||
end
|
||
end
|
||
|
||
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)
|
||
local entry = { chance = chance }
|
||
if species == 0 then
|
||
entry.timeGroup = level
|
||
local tg = timeFishGroups[level]
|
||
if tg then
|
||
entry.day = tg.day
|
||
entry.nite = tg.nite
|
||
entry.species = tg.day.species
|
||
entry.level = tg.day.level
|
||
else
|
||
entry.species = 0
|
||
entry.level = level
|
||
end
|
||
else
|
||
entry.species = self:speciesName(species)
|
||
entry.level = level
|
||
end
|
||
list[#list + 1] = entry
|
||
-- 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,
|
||
timeFishGroups = timeFishGroups,
|
||
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
|
||
|
||
-- Four OAM tiles at $31 -- normal, statused, fainted, empty -- and OBJ
|
||
-- colour 0 is transparent (engine/battle/trainer_huds.asm:47-99, :225-232).
|
||
local balls = self.symbols["LoadBallIconGFX.gfx"] or BALL_ICON_GFX
|
||
self:write2bpp(self.rom:bytes(balls[1], balls[2], 4 * 16), 32, 8,
|
||
"battle/hud/balls.png", true)
|
||
hud.balls = "assets/generated/battle/hud/balls.png"
|
||
hud.ballsFirstTile = 0x31
|
||
|
||
-- "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.billsPc = self:billsPcGfx()
|
||
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",
|
||
heart = "HeartEmote",
|
||
bolt = "BoltEmote",
|
||
sleep = "SleepEmote",
|
||
fish = "FishEmote",
|
||
}) 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
|
||
|
||
-- StatsScreenPageTilesGFX (gfx/font.asm:23), the 17 tiles
|
||
-- LoadStatsScreenPageTilesGFX lands at vTiles2 $31 (engine/gfx/load_font.asm:90).
|
||
local hpBarBorder = self.symbols["EnemyHPBarBorderGFX"]
|
||
if hpBarBorder then
|
||
local address = hpBarBorder[2] - 17 * 16
|
||
self:write2bpp(self.rom:bytes(hpBarBorder[1], address, 17 * 16),
|
||
17 * 8, 8, "menu/stats_tiles.png")
|
||
out.stats = {
|
||
sheet = "assets/generated/menu/stats_tiles.png",
|
||
tiles = 17,
|
||
firstTile = 0x31,
|
||
}
|
||
end
|
||
|
||
-- Goldenrod Game Corner: Slot Machine graphics assets
|
||
local CacheFs = require("src.import.CacheFs")
|
||
local function packBytes(bytes)
|
||
local chars = {}
|
||
for i = 1, #bytes do chars[i] = string.char(bytes[i]) end
|
||
return table.concat(chars)
|
||
end
|
||
local function writeRaw(relative, bytes)
|
||
local ok, writeError = CacheFs.write(
|
||
"assets/generated/" .. relative, packBytes(bytes))
|
||
if not ok then
|
||
error("could not write " .. relative .. ": " .. tostring(writeError))
|
||
end
|
||
end
|
||
|
||
-- Canonical sheet sizes match the cart art the UI indexes (and pret's
|
||
-- gfx/slots + gfx/card_flip PNGs). ROM LZ streams are those sheets after
|
||
-- Makefile gfx transforms; reverse what the decompressed bytes still carry.
|
||
local SLOTS1_W, SLOTS1_H = 16, 152
|
||
local SLOTS2_W, SLOTS2_H = 16, 256
|
||
local SLOTS3_W, SLOTS3_H = 24, 240
|
||
local CARD1_W, CARD1_H = 128, 32
|
||
local CARD2_W, CARD2_H = 24, 160
|
||
local CARD3_W, CARD3_H = 8, 56
|
||
|
||
local function pad2bpp(raw, width, height)
|
||
local need = width * height / 4
|
||
while #raw < need do raw[#raw + 1] = 0 end
|
||
while #raw > need do table.remove(raw) end
|
||
return raw
|
||
end
|
||
|
||
local function writeSheet(raw, width, height, relative, transparent)
|
||
self:write2bpp(pad2bpp(raw, width, height), width, height, relative,
|
||
transparent)
|
||
end
|
||
|
||
-- Slots3LZ is unique 8x16 OBJ columns (interleave + remove-duplicates +
|
||
-- remove-xflip). Rebuild the 24x240 actor sheet the UI quads expect from
|
||
-- OAMData_SlotsGolem / Chansey* / Egg (data/sprite_anims/oam.asm), same
|
||
-- pattern as title-screen Ho-Oh frame composition above.
|
||
local function composeSlotsActors(raw)
|
||
local tileCount = math.floor(#raw / 16)
|
||
local tiles = {}
|
||
for index = 0, tileCount - 1 do
|
||
local one = {}
|
||
for b = 1, 16 do one[b] = raw[index * 16 + b] or 0 end
|
||
tiles[index] = ImageWriter.decode2bpp(one, 8, 8, true)
|
||
end
|
||
local sheet = ImageWriter.blank(SLOTS3_W, SLOTS3_H, 1, 1, 1, 0)
|
||
local function blit8x16(tileId, dx, dy, flipX)
|
||
local top, bot = tiles[tileId], tiles[tileId + 1]
|
||
if not (top and bot) then return end
|
||
ImageWriter.blit(sheet, top, dx, dy, 0, 0, 8, 8, flipX)
|
||
ImageWriter.blit(sheet, bot, dx, dy + 8, 0, 0, 8, 8, flipX)
|
||
end
|
||
local function blitPose(poseY, base, entries)
|
||
for _, e in ipairs(entries) do
|
||
blit8x16(base + e.t, (e.x + 2) * 8, poseY + (e.y + 2) * 8, e.xf)
|
||
end
|
||
end
|
||
local golem = {
|
||
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
|
||
{ x = 0, y = -2, t = 0x00, xf = true },
|
||
{ x = -2, y = 0, t = 0x04 }, { x = -1, y = 0, t = 0x06 },
|
||
{ x = 0, y = 0, t = 0x04, xf = true },
|
||
}
|
||
local chansey = {
|
||
{
|
||
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
|
||
{ x = 0, y = -2, t = 0x04 },
|
||
{ x = -2, y = 0, t = 0x06 }, { x = -1, y = 0, t = 0x08 },
|
||
{ x = 0, y = 0, t = 0x0a },
|
||
},
|
||
{
|
||
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
|
||
{ x = 0, y = -2, t = 0x04 },
|
||
{ x = -2, y = 0, t = 0x0c }, { x = -1, y = 0, t = 0x0e },
|
||
{ x = 0, y = 0, t = 0x10 },
|
||
},
|
||
{
|
||
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
|
||
{ x = 0, y = -2, t = 0x04 },
|
||
{ x = -2, y = 0, t = 0x12 }, { x = -1, y = 0, t = 0x14 },
|
||
{ x = 0, y = 0, t = 0x16 },
|
||
},
|
||
{
|
||
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
|
||
{ x = 0, y = -2, t = 0x04 },
|
||
{ x = -2, y = 0, t = 0x18 }, { x = -1, y = 0, t = 0x1a },
|
||
{ x = 0, y = 0, t = 0x1c },
|
||
},
|
||
{
|
||
{ x = -2, y = -2, t = 0x1e }, { x = -1, y = -2, t = 0x20 },
|
||
{ x = 0, y = -2, t = 0x22 },
|
||
{ x = -2, y = 0, t = 0x24 }, { x = -1, y = 0, t = 0x26 },
|
||
{ x = 0, y = 0, t = 0x28 },
|
||
},
|
||
}
|
||
blitPose(0, 0x00, golem)
|
||
blitPose(32, 0x08, golem)
|
||
for index, frame in ipairs(chansey) do
|
||
blitPose(32 + index * 32, 0x10, frame)
|
||
end
|
||
blit8x16(0x3a, 0, 224, false)
|
||
return sheet
|
||
end
|
||
|
||
-- card_flip_2.2bpp uses --remove-whitespace: blank tiles in column 2 of the
|
||
-- 3-wide header strip (indices 2,5,...,23) are dropped from the ROM stream.
|
||
-- Re-insert them so HEADER_TILE_MAP / MON_ANCHORS (pret sheet indices) work.
|
||
local function expandCardFlip2(compact)
|
||
local need = CARD2_W * CARD2_H / 4
|
||
local out = {}
|
||
for i = 1, need do out[i] = 0 end
|
||
local whitespace = {
|
||
[2] = true, [5] = true, [8] = true, [11] = true,
|
||
[14] = true, [17] = true, [20] = true, [23] = true,
|
||
}
|
||
local src = 0
|
||
for tile = 0, 59 do
|
||
if not whitespace[tile] then
|
||
for b = 1, 16 do
|
||
out[tile * 16 + b] = compact[src * 16 + b] or 0
|
||
end
|
||
src = src + 1
|
||
end
|
||
end
|
||
return out
|
||
end
|
||
|
||
local slots = nil
|
||
if self.symbols["Slots1LZ"] then
|
||
-- --trim-whitespace drops the final empty tile (37 of 38).
|
||
local raw1 = self:decompressLz3Symbol("Slots1LZ")
|
||
writeSheet(raw1, SLOTS1_W, SLOTS1_H, "slots/gold_slots_1.png")
|
||
slots = slots or {}
|
||
slots.sheet1 = "assets/generated/slots/gold_slots_1.png"
|
||
end
|
||
if self.symbols["Slots2LZ"] then
|
||
local raw2 = ImageWriter.deinterleave(
|
||
self:decompressLz3Symbol("Slots2LZ"), SLOTS2_W)
|
||
-- Commercial Gold stores the Seven symbol with inverted bit polarity.
|
||
for i = 1, math.min(64, #raw2) do
|
||
raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF)
|
||
end
|
||
writeSheet(raw2, SLOTS2_W, SLOTS2_H, "slots/gold_slots_2.png")
|
||
slots = slots or {}
|
||
slots.sheet2 = "assets/generated/slots/gold_slots_2.png"
|
||
end
|
||
if self.symbols["Slots3LZ"] then
|
||
local raw3 = self:decompressLz3Symbol("Slots3LZ")
|
||
local actors = composeSlotsActors(raw3)
|
||
self:save(actors, "slots/gold_slots_3.png")
|
||
self:save(actors, "slots/gold_slots_actors.png")
|
||
slots = slots or {}
|
||
slots.sheet3 = "assets/generated/slots/gold_slots_3.png"
|
||
end
|
||
if self.symbols["SlotsTilemap"] then
|
||
local symbol = self:symbol("SlotsTilemap")
|
||
local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12)
|
||
writeRaw("slots/gold_slots.tilemap", tm)
|
||
slots = slots or {}
|
||
slots.tilemap = "assets/generated/slots/gold_slots.tilemap"
|
||
end
|
||
if slots then out.slots = slots end
|
||
|
||
-- Goldenrod Game Corner: Card Flip graphics assets
|
||
local cardFlip = nil
|
||
if self.symbols["CardFlipLZ01"] then
|
||
-- --trim-whitespace: 62 of 64 tiles in the ROM stream.
|
||
local raw1 = self:decompressLz3Symbol("CardFlipLZ01")
|
||
writeSheet(raw1, CARD1_W, CARD1_H, "card_flip/card_flip_1.png")
|
||
cardFlip = cardFlip or {}
|
||
cardFlip.sheet1 = "assets/generated/card_flip/card_flip_1.png"
|
||
end
|
||
if self.symbols["CardFlipLZ02"] then
|
||
local raw2 = expandCardFlip2(self:decompressLz3Symbol("CardFlipLZ02"))
|
||
writeSheet(raw2, CARD2_W, CARD2_H, "card_flip/card_flip_2.png")
|
||
cardFlip = cardFlip or {}
|
||
cardFlip.sheet2 = "assets/generated/card_flip/card_flip_2.png"
|
||
end
|
||
if self.symbols["CardFlipLZ03"] then
|
||
local raw3 = self:decompressLz3Symbol("CardFlipLZ03")
|
||
writeSheet(raw3, CARD3_W, CARD3_H, "card_flip/card_flip_3.png")
|
||
cardFlip = cardFlip or {}
|
||
cardFlip.sheet3 = "assets/generated/card_flip/card_flip_3.png"
|
||
end
|
||
if self.symbols["CardFlipOnButtonGFX"] then
|
||
local symbol = self:symbol("CardFlipOnButtonGFX")
|
||
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png")
|
||
cardFlip = cardFlip or {}
|
||
cardFlip.on = "assets/generated/card_flip/on.png"
|
||
end
|
||
if self.symbols["CardFlipOffButtonGFX"] then
|
||
local symbol = self:symbol("CardFlipOffButtonGFX")
|
||
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png")
|
||
cardFlip = cardFlip or {}
|
||
cardFlip.off = "assets/generated/card_flip/off.png"
|
||
end
|
||
if self.symbols["CardFlipTilemap"] then
|
||
local symbol = self:symbol("CardFlipTilemap")
|
||
local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12)
|
||
writeRaw("card_flip/card_flip.tilemap", tm)
|
||
cardFlip = cardFlip or {}
|
||
cardFlip.tilemap = "assets/generated/card_flip/card_flip.tilemap"
|
||
end
|
||
if cardFlip then out.cardFlip = cardFlip 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
|
||
|
||
-- BillsPC_InitGFX's four tiles at vTiles2 $5c
|
||
-- (engine/pokemon/bills_pc.asm:2170-2173)
|
||
function RomExtractorGen2:billsPcGfx()
|
||
if not self.symbols["PCMailGFX"] then return nil end
|
||
local pc = {}
|
||
local gfx = self:symbol("PCMailGFX")
|
||
self:write2bpp(self.rom:bytes(gfx.bank, gfx.address, 4 * 16),
|
||
32, 8, "pc/mail_item.png")
|
||
pc.icons = "assets/generated/pc/mail_item.png"
|
||
pc.firstTile = 0x5c
|
||
pc.palette = self:predefPal(PREDEFPAL_POKEDEX)
|
||
-- gfx/pc/orange.pal
|
||
if self.symbols["BillsPCOrangePalette"] then
|
||
local orange = self:symbol("BillsPCOrangePalette")
|
||
pc.orangePalette = self:colors(orange.bank, orange.address, 4)
|
||
end
|
||
return pc
|
||
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.text = self:extractText()
|
||
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
|