local bit = require("bit") local ImageWriter = require("src.import.ImageWriter") local LuaWriter = require("src.import.LuaWriter") local Rom = require("src.import.Rom") local RomExtractor = {} RomExtractor.__index = RomExtractor local STAGE_COUNT = 17 local function copy(value, seen) if type(value) ~= "table" then return value end seen = seen or {} if seen[value] then return seen[value] end local result = {} seen[value] = result for key, item in pairs(value) do result[copy(key, seen)] = copy(item, seen) end return result end local function append(target, source) for _, value in ipairs(source) do target[#target + 1] = value end end local function unique(values) local result, seen = {}, {} for _, value in ipairs(values) do if not seen[value] then seen[value] = true result[#result + 1] = value end end return result end local function sorted(values) table.sort(values) return values end local function startsWith(value, prefix) return value:sub(1, #prefix) == prefix end local function round(value) return math.floor(value + 0.5) end local function hex(prefix, value) return ("%s_%02X"):format(prefix, value) end function RomExtractor.new(romData, manifest, progress) return setmetatable({ rom = Rom.new(romData), manifest = manifest, symbols = manifest.symbols, progress = progress, stage = 0, }, RomExtractor) end function RomExtractor: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 function RomExtractor:beginStage(name) self.stage = self.stage + 1 if self.progress then self.progress(self.stage - 1, STAGE_COUNT, name, 0, 1) end end function RomExtractor:tick(name, current, total) if self.progress then self.progress(self.stage - 1 + current / total, STAGE_COUNT, name, current, total) end end function RomExtractor:write(name, value) LuaWriter.write("data/generated/" .. name .. ".lua", value) end function RomExtractor:save(image, relative) ImageWriter.save(image, "assets/generated/" .. relative) end function RomExtractor:readTerminated(bank, address, terminator, limit) local out = {} for offset = 0, (limit or 256) - 1 do local value = self.rom:byte(bank, address + offset) if value == terminator then return out end out[#out + 1] = value end error(("unterminated byte list at %02X:%04X"):format(bank, address)) end function RomExtractor:write2bpp(raw, width, height, relative, transparent) local image = ImageWriter.decode2bpp(raw, width, height, transparent) self:save(image, relative) end function RomExtractor:writeCompressedPic(label, relative) local symbol = self:symbol(label) local compressed = self.rom:bytes( symbol.bank, symbol.address, 0x8000 - symbol.address) local raw, width = Rom.decompressPic(compressed) local image = ImageWriter.matteColor0( ImageWriter.decode2bpp(raw, width * 8, width * 8)) self:save(image, relative) return width end function RomExtractor:extractConstants() self:beginStage("Game constants") local data = self.manifest.constants self:write("constants", data) self:tick("Game constants", 1, 1) return data end function RomExtractor:extractTilesets() self:beginStage("World tiles") local manifest = self.manifest local order = manifest.constants.tilesetOrder local metadata = manifest.tilesets local animations = manifest.tileAnimations assert(#metadata == #order, "tileset metadata count does not match constants") local headers = self:symbol("Tilesets") local warpPointers = self:symbol("WarpTileIDPointers") local doorPointers = self:symbol("DoorTileIDPointers") local doors, address = {}, doorPointers.address while true do local tilesetId = self.rom:byte(doorPointers.bank, address) if tilesetId == 0xFF then break end local pointer = self.rom:word(doorPointers.bank, address + 1) doors[tilesetId] = self:readTerminated( doorPointers.bank, pointer, 0) address = address + 3 end local out, written = {}, {} for index, constName in ipairs(order) do local spec = metadata[index] assert(spec.id == constName, "tileset metadata is out of order") local rowAddress = headers.address + (index - 1) * 12 local gfxBank = self.rom:byte(headers.bank, rowAddress) local blockPointer = self.rom:word(headers.bank, rowAddress + 1) local gfxPointer = self.rom:word(headers.bank, rowAddress + 3) local collisionPointer = self.rom:word(headers.bank, rowAddress + 5) local counters = self.rom:bytes(headers.bank, rowAddress + 7, 3) local grass = self.rom:byte(headers.bank, rowAddress + 10) local animationId = self.rom:byte(headers.bank, rowAddress + 11) assert(animationId < #animations, constName .. ": unknown tile animation") local blocksRaw = self.rom:bytes( gfxBank, blockPointer, spec.blockCount * 16) local blocks = {} for offset = 1, #blocksRaw, 16 do local block = {} for pos = offset, offset + 15 do block[#block + 1] = blocksRaw[pos] end blocks[#blocks + 1] = block end -- Red/Blue keep collision lists in ROM0; Yellow moved them to bank 1 -- (pokeyellow Overworld_Coll at 01:4ac2). Pointers in $4000-$7FFF are -- banked; treat ROM0-range pointers as bank 0. local collBank = collisionPointer < 0x4000 and 0 or 1 local walkable = sorted(self:readTerminated( collBank, collisionPointer, 0xFF)) local warpPointer = self.rom:word( warpPointers.bank, warpPointers.address + (index - 1) * 2) local warpTiles = unique(self:readTerminated( warpPointers.bank, warpPointer, 0xFF)) sorted(warpTiles) local base = spec.imageBase if not written[base] then local byteLength = spec.imageWidth * spec.imageHeight / 4 local storedLength = blockPointer - gfxPointer assert(storedLength >= 0 and storedLength <= byteLength and storedLength % 16 == 0, constName .. ": invalid stored tileset graphics length") local pixels = self.rom:bytes(gfxBank, gfxPointer, storedLength) while #pixels < byteLength do pixels[#pixels + 1] = 0 end self:write2bpp(pixels, spec.imageWidth, spec.imageHeight, "tilesets/" .. base .. ".png") written[base] = true end local counterTiles = {} for _, value in ipairs(counters) do if value ~= 0xFF then counterTiles[#counterTiles + 1] = value end end local grassTile if grass ~= 0xFF then grassTile = grass end out[constName] = { id = constName, source = ("ROM:Tilesets[%d]"):format(index - 1), image = "assets/generated/tilesets/" .. base .. ".png", imageWidth = spec.imageWidth, imageHeight = spec.imageHeight, tilesPerRow = spec.imageWidth / 8, blocks = blocks, walkable = walkable, counterTiles = counterTiles, grassTile = grassTile, doorTiles = sorted(copy(doors[index - 1] or {})), warpTiles = warpTiles, animation = animations[animationId + 1], } self:tick("World tiles", index, #order + 4) end for number = 1, 3 do local symbol = self:symbol("FlowerTile" .. number) self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "tilesets/flower" .. number .. ".png") self:tick("World tiles", #order + number, #order + 4) end local spinner = self:symbol("SpinnerArrowAnimTiles") self:write2bpp(self.rom:bytes(spinner.bank, spinner.address, 64), 32, 8, "tilesets/spinners.png") self:write("tilesets", out) self:tick("World tiles", #order + 4, #order + 4) return out end function RomExtractor:extractMaps() self:beginStage("Maps") local manifest = self.manifest local mapOrder = manifest.constants.mapOrder local dimensions = manifest.constants.maps local metadata = manifest.maps local tilesets = manifest.constants.tilesetOrder local sprites = manifest.constants.spriteOrder local movementNames = { [0xFE] = "WALK", [0xFF] = "STAY" } local rangeNames = { [0x00] = "ANY_DIR", [0x01] = "UP_DOWN", [0x02] = "LEFT_RIGHT", [0x10] = "BOULDER_MOVEMENT_BYTE_2", [0xD0] = "DOWN", [0xD1] = "UP", [0xD2] = "LEFT", [0xD3] = "RIGHT", [0xFF] = "NONE", } local directions = { { "north", 0x08 }, { "south", 0x04 }, { "west", 0x02 }, { "east", 0x01 }, } local function signed(value) return value >= 0x80 and value - 0x100 or value end local function mapId(value) if value == 0xFF then return "LAST_MAP" end assert(value < #mapOrder, ("unknown map id $%02X"):format(value)) return mapOrder[value + 1] end local keys = {} for key in pairs(metadata) do keys[#keys + 1] = key end table.sort(keys) local out = {} for mapIndex, constName in ipairs(keys) do local spec, dims = metadata[constName], dimensions[constName] local label = spec.label local header = self:symbol(label .. "_h") local address = header.address local tilesetId = self.rom:byte(header.bank, address) local height = self.rom:byte(header.bank, address + 1) local width = self.rom:byte(header.bank, address + 2) assert(width == dims.width and height == dims.height, constName .. ": ROM dimensions do not match metadata") assert(tilesetId < #tilesets, constName .. ": unknown tileset id") local blockPointer = self.rom:word(header.bank, address + 3) local connectionFlags = self.rom:byte(header.bank, address + 9) address = address + 10 local connections = {} for _, directionSpec in ipairs(directions) do local direction, flag = directionSpec[1], directionSpec[2] if bit.band(connectionFlags, flag) ~= 0 then local targetId = self.rom:byte(header.bank, address) local yOffset = signed(self.rom:byte(header.bank, address + 7)) local xOffset = signed(self.rom:byte(header.bank, address + 8)) local encoded = (direction == "north" or direction == "south") and xOffset or yOffset assert(encoded % 2 == 0, constName .. ": odd connection offset") connections[direction] = { map = mapId(targetId), offset = -encoded / 2, } address = address + 11 end end assert(bit.band(connectionFlags, 0xF0) == 0, constName .. ": unknown connection flags") local objectPointer = self.rom:word(header.bank, address) local objectAddress = objectPointer local borderBlock = self.rom:byte(header.bank, objectAddress) objectAddress = objectAddress + 1 local warpCount = self.rom:byte(header.bank, objectAddress) objectAddress = objectAddress + 1 local warps = {} for _ = 1, warpCount do local row = self.rom:bytes(header.bank, objectAddress, 4) warps[#warps + 1] = { x = row[2], y = row[1], destMap = mapId(row[4]), destWarp = row[3] + 1, } objectAddress = objectAddress + 4 end local signCount = self.rom:byte(header.bank, objectAddress) objectAddress = objectAddress + 1 assert(signCount == #spec.signTexts, constName .. ": sign count mismatch") local signs = {} for _, signText in ipairs(spec.signTexts) do local row = self.rom:bytes(header.bank, objectAddress, 3) signs[#signs + 1] = { x = row[2], y = row[1], text = signText } objectAddress = objectAddress + 3 end local objectCount = self.rom:byte(header.bank, objectAddress) objectAddress = objectAddress + 1 assert(objectCount == #spec.objects, constName .. ": object count mismatch") local objects = {} for index, objectSpec in ipairs(spec.objects) do local row = self.rom:bytes(header.bank, objectAddress, 6) local spriteId, y, x = row[1], row[2], row[3] local movementId, rangeId, textId = row[4], row[5], row[6] assert(spriteId >= 1 and spriteId <= #sprites, constName .. ": unknown object sprite") assert(movementNames[movementId] and rangeNames[rangeId], constName .. ": unknown movement encoding") local object = { index = index, x = x - 4, y = y - 4, sprite = sprites[spriteId], movement = movementNames[movementId], range = rangeNames[rangeId], text = objectSpec.text, } objectAddress = objectAddress + 6 if bit.band(textId, 0x80) ~= 0 then assert(objectSpec.item, constName .. ": unexpected item payload") object.item = objectSpec.item objectAddress = objectAddress + 1 elseif bit.band(textId, 0x40) ~= 0 then local extra = self.rom:bytes(header.bank, objectAddress, 2) objectAddress = objectAddress + 2 if objectSpec.trainerClass then object.trainerClass = objectSpec.trainerClass object.trainerParty = type(objectSpec.trainerParty) == "string" and objectSpec.trainerParty or extra[2] elseif objectSpec.pokemon then object.pokemon = objectSpec.pokemon object.level = extra[2] else error(constName .. ": unexpected trainer or Pokemon payload") end else assert(not objectSpec.item and not objectSpec.trainerClass and not objectSpec.pokemon, constName .. ": missing extra payload") end if objectSpec.name then object.name = objectSpec.name end if objectSpec.hidden ~= nil then object.hidden = objectSpec.hidden end objects[#objects + 1] = object end local expectedBlocks = width * height assert(spec.blockLength <= expectedBlocks, constName .. ": block payload exceeds map dimensions") local blocks = self.rom:bytes( header.bank, blockPointer, spec.blockLength) while #blocks < expectedBlocks do blocks[#blocks + 1] = borderBlock end out[constName] = { id = constName, label = label, index = dims.index, source = ("ROM:%02X:%04X"):format(header.bank, header.address), tileset = tilesets[tilesetId + 1], width = width, height = height, blocks = blocks, borderBlock = borderBlock, connections = connections, warps = warps, signs = signs, objects = objects, } self:tick("Maps", mapIndex, #keys) end -- keep the extract ROM-faithful; Data:seedDefaults applies the #189 -- S.S. Anne 1F cabin reorder at load time self:write("maps", out) return out end function RomExtractor:extractFont() self:beginStage("Fonts") local mainSymbol = self:symbol("FontGraphics") local raw = self.rom:bytes(mainSymbol.bank, mainSymbol.address, 128 * 8) local image = ImageWriter.blank(128, 64, 0, 0, 0, 0) for tile = 0, 127 do local tileX, tileY = tile % 16 * 8, math.floor(tile / 16) * 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 self:save(image, "fonts/font.png") self:tick("Fonts", 1, 2) local extraSymbol = self:symbol("TextBoxGraphics") local shaded = ImageWriter.decode2bpp( self.rom:bytes(extraSymbol.bank, extraSymbol.address, 32 * 16), 128, 16) local extra = ImageWriter.blank(128, 16, 0, 0, 0, 0) for y = 0, 15 do for x = 0, 127 do local r = shaded:getPixel(x, y) if r < 0.5 then extra:setPixel(x, y, 0, 0, 0, 1) end end end local pokedex = self:symbol("PokedexTileGraphics") local dex = ImageWriter.decode2bpp( self.rom:bytes(pokedex.bank, pokedex.address, 32), 16, 8) for y = 0, 7 do for x = 0, 15 do local r = dex:getPixel(x, y) extra:setPixel(x, y, 0, 0, 0, r < 0.5 and 1 or 0) end end self:save(extra, "fonts/font_extra.png") local data = { source = "ROM:FontGraphics, TextBoxGraphics, PokedexTileGraphics", image = "assets/generated/fonts/font.png", imageExtra = "assets/generated/fonts/font_extra.png", mainBase = 0x80, extraBase = 0x60, glyphsPerRow = 16, charmap = self.manifest.fontCharmap, } self:write("font", data) self:tick("Fonts", 2, 2) return data end function RomExtractor:extractSprites() self:beginStage("Overworld sprites") local order = self.manifest.constants.spriteOrder local metadata = self.manifest.sprites.order local pointerTable = self:symbol("SpriteSheetPointerTable") assert(#metadata == #order, "sprite metadata count does not match constants") local out, written = {}, {} for index, constName in ipairs(order) do local spec = metadata[index] assert(spec.id == constName, "sprite metadata is out of order") local address = pointerTable.address + (index - 1) * 4 local pointer = self.rom:word(pointerTable.bank, address) local firstHalf = self.rom:byte(pointerTable.bank, address + 2) local bank = self.rom:byte(pointerTable.bank, address + 3) local width = spec.imageWidth local height = spec.imageHeight local byteLength = width * height / 4 local frames = height / 16 local expected = firstHalf * (frames >= 6 and 2 or 1) if byteLength ~= expected then -- Commercial ROM sheet length wins over pret PNG atlases (Yellow nurse -- PNG is taller than the 12-tile SpriteSheetPointerTable entry). byteLength = expected assert(byteLength * 4 % width == 0, constName .. ": ROM sprite length not tile-aligned") height = byteLength * 4 / width frames = height / 16 expected = firstHalf * (frames >= 6 and 2 or 1) assert(byteLength == expected, constName .. ": sprite length mismatch") end local base = spec.imageBase if not written[base] then self:write2bpp(self.rom:bytes(bank, pointer, byteLength), width, height, "sprites/" .. base .. ".png", true) written[base] = true end out[constName] = { id = constName, source = ("ROM:SpriteSheetPointerTable[%d]"):format(index - 1), image = "assets/generated/sprites/" .. base .. ".png", frames = frames, walker = frames >= 6, } self:tick("Overworld sprites", index, #order + 1) end local bike = self.manifest.sprites.bike local bikeSymbol = self:symbol(bike.label) self:write2bpp( self.rom:bytes(bikeSymbol.bank, bikeSymbol.address, bike.imageWidth * bike.imageHeight / 4), bike.imageWidth, bike.imageHeight, "sprites/" .. bike.imageBase .. ".png", true) local bikeFrames = bike.imageHeight / 16 out.SPRITE_RED_BIKE = { id = "SPRITE_RED_BIKE", source = "ROM:RedBikeSprite", image = "assets/generated/sprites/red_bike.png", frames = bikeFrames, walker = bikeFrames >= 6, } self:write("sprites", out) self:tick("Overworld sprites", #order + 1, #order + 1) return out end function RomExtractor:animationFlags(count) local pointerTable = self:symbol("AttackAnimationPointers") local flags = {} for index = 0, count - 1 do local address = self.rom:word( pointerTable.bank, pointerTable.address + index * 2) local shake, flash, ended = false, false, false for _ = 1, 256 do local first = self.rom:byte(pointerTable.bank, address) if first == 0xFF then ended = true; break end if first >= 0xD8 then shake = shake or first == 0xFB flash = flash or first == 0xF8 or first == 0xFE address = address + 2 else address = address + 3 end end assert(ended, "unterminated move animation " .. (index + 1)) flags[#flags + 1] = { shake, flash } end return flags end function RomExtractor:extractMoves() self:beginStage("Moves") local order = self.manifest.constants.moveOrder local types = {} for name, value in pairs(self.manifest.constants.types) do types[value] = name end local effects = self.manifest.moveEffects local charmap = self.manifest.charmap local moves = self:symbol("Moves") local names = self:symbol("MoveNames") local sounds = self:symbol("MoveSoundTable") local flags = self:animationFlags(#order) local decodedNames, address = {}, names.address for _ = 1, #order do local value, consumed = self.rom:readString( names.bank, address, charmap, 0x50, 32) decodedNames[#decodedNames + 1] = value address = address + consumed end local out = {} for index, moveId in ipairs(order) do local row = self.rom:bytes(moves.bank, moves.address + (index - 1) * 6, 6) assert(row[1] == index, "Moves row stores wrong animation id") local effect = effects[row[2] + 1] or hex("EFFECT", row[2]) local typeName = types[row[4]] or hex("TYPE", row[4]) local soundId, pitch, tempo = unpack(self.rom:bytes( sounds.bank, sounds.address + (index - 1) * 3, 3)) local animation = { sound = self.manifest.sfxKeys[tostring(soundId)] or hex("SFX", soundId), pitch = pitch, tempo = tempo, } if flags[index][1] then animation.shake = true end if flags[index][2] then animation.flash = true end out[moveId] = { id = moveId, index = index, name = decodedNames[index], source = ("ROM:Moves[%d]"):format(index), effect = effect, power = row[3], type = typeName, accuracy = round(row[5] * 100 / 255), pp = row[6], anim = animation, } self:tick("Moves", index, #order) end self:write("moves", out) return out end function RomExtractor:extractBattleAnimations() self:beginStage("Battle animations") local metadata = self.manifest.battleAnimations local moveOrder = self.manifest.constants.moveOrder assert(#moveOrder == metadata.moveCount, "battle animation move count does not match constants") local total = metadata.baseCoordCount + metadata.frameBlockCount + metadata.subanimCount + metadata.moveCount + #metadata.miscAnimations + 3 local completed = 0 local function tick() completed = completed + 1 self:tick("Battle animations", completed, total) end local coordsSymbol = self:symbol("FrameBlockBaseCoords") local baseCoords = {} for index = 0, metadata.baseCoordCount - 1 do local row = self.rom:bytes( coordsSymbol.bank, coordsSymbol.address + index * 2, 2) baseCoords[index] = { y = row[1], x = row[2] } tick() end local blocksSymbol = self:symbol("FrameBlockPointers") local frameBlocks = {} for index = 0, metadata.frameBlockCount - 1 do local address = self.rom:word( blocksSymbol.bank, blocksSymbol.address + index * 2) local count = self.rom:byte(blocksSymbol.bank, address) address = address + 1 local entries = {} for _ = 1, count do local row = self.rom:bytes(blocksSymbol.bank, address, 4) local attrs = row[4] local entry = { y = row[1], x = row[2], tile = row[3], xflip = bit.band(attrs, 0x20) ~= 0, yflip = bit.band(attrs, 0x40) ~= 0, } if bit.band(attrs, 0x80) ~= 0 then entry.prio = true end if bit.band(attrs, 0x10) ~= 0 then entry.pal1 = true end entries[#entries + 1] = entry address = address + 4 end frameBlocks[index] = entries tick() end local subanimSymbol = self:symbol("SubanimationPointers") local subanims = {} for index = 0, metadata.subanimCount - 1 do local address = self.rom:word( subanimSymbol.bank, subanimSymbol.address + index * 2) local packed = self.rom:byte(subanimSymbol.bank, address) local typeId = math.floor(packed / 0x20) local count = packed % 0x20 local typeName = metadata.subanimTypes[typeId + 1] assert(typeName, "subanimation " .. index .. " has unknown type") address = address + 1 local entries = {} for _ = 1, count do local row = self.rom:bytes(subanimSymbol.bank, address, 3) assert(row[1] < metadata.frameBlockCount, "subanimation " .. index .. " has invalid frame block") assert(row[2] < metadata.baseCoordCount, "subanimation " .. index .. " has invalid base coord") entries[#entries + 1] = { block = row[1], coord = row[2], mode = row[3], } address = address + 3 end subanims[index] = { type = typeName, blocks = entries } tick() end local tilesTable = self:symbol("MoveAnimationTilesPointers") assert(#metadata.tilesheets == 3, "expected three battle animation tilesheets") local tileRows = {} for index = 0, 2 do local row = self.rom:bytes( tilesTable.bank, tilesTable.address + index * 4, 4) assert(row[4] == 0xFF, "battle animation tilesheet " .. index .. " has invalid padding") local pointer = row[2] + row[3] * 0x100 local expected = self:symbol("MoveAnimationTiles" .. index) assert(expected.bank == tilesTable.bank and expected.address == pointer, "battle animation tilesheet " .. index .. " pointer differs") tileRows[index] = { count = row[1], pointer = pointer, spec = metadata.tilesheets[index + 1], } end local imagePayloads = {} for index = 0, 2 do local row = tileRows[index] local path = row.spec.path local payload = imagePayloads[path] if payload then assert(payload.pointer == row.pointer, "shared battle animation atlas has two pointers") else payload = { pointer = row.pointer, tiles = 0, spec = row.spec } imagePayloads[path] = payload end payload.tiles = math.max(payload.tiles, row.count) end local prefix = "assets/generated/" for path, payload in pairs(imagePayloads) do local spec = payload.spec local byteLength = spec.width * spec.height / 4 local storedLength = payload.tiles * 16 assert(storedLength <= byteLength, path .. ": battle animation atlas is too large") local raw = self.rom:bytes( tilesTable.bank, payload.pointer, storedLength) while #raw < byteLength do raw[#raw + 1] = 0 end assert(startsWith(path, prefix), "invalid generated asset path") self:write2bpp(raw, spec.width, spec.height, path:sub(#prefix + 1), true) end local tilesheets = {} for index = 0, 2 do local row, spec = tileRows[index], tileRows[index].spec tilesheets[index] = { path = spec.path, width = spec.width, height = spec.height, tiles = row.count, source = spec.source, } tick() end local moveNames = copy(moveOrder) append(moveNames, metadata.miscAnimations) local pointerTable = self:symbol("AttackAnimationPointers") local moveAnims = {} for index, name in ipairs(moveNames) do local address = self.rom:word( pointerTable.bank, pointerTable.address + (index - 1) * 2) local sequence, ended = {}, false for _ = 1, 256 do local first = self.rom:byte(pointerTable.bank, address) if first == 0xFF then ended = true; break end local sound = self.rom:byte(pointerTable.bank, address + 1) local row if first >= metadata.firstSpecialEffect then local effect = metadata.specialEffects[tostring(first)] assert(effect, name .. ": unknown special effect") row = { effect = effect } address = address + 2 else local subanim = self.rom:byte(pointerTable.bank, address + 2) local delay = first % 0x40 local tileset = math.floor(first / 0x40) assert(delay > 0, name .. ": zero animation delay") assert(subanim < metadata.subanimCount, name .. ": unknown subanimation") assert(tilesheets[tileset], name .. ": unknown animation tileset") row = { subanim = subanim, tileset = tileset, delay = delay } address = address + 3 end if sound ~= 0xFF then assert(sound < #moveOrder, name .. ": unknown animation sound") row.sound = moveOrder[sound + 1] end sequence[#sequence + 1] = row end assert(ended, name .. ": unterminated battle animation") moveAnims[name] = { source = ("ROM:AttackAnimationPointers[%d]"):format(index - 1), seq = sequence, } tick() end for name, anim in pairs(moveAnims) do for _, row in ipairs(anim.seq) do if row.subanim then local sheet = tilesheets[row.tileset] for _, blockRef in ipairs(subanims[row.subanim].blocks) do for _, tile in ipairs(frameBlocks[blockRef.block]) do assert(tile.tile < sheet.tiles, name .. ": animation tile is out of range") end end end end end local out = { tilesheets = tilesheets, baseCoords = baseCoords, frameBlocks = frameBlocks, subanims = subanims, moveAnims = moveAnims, } self:write("battle_anims", out) return out end function RomExtractor:nybbles(raw, count) local out = {} for _, value in ipairs(raw) do out[#out + 1], out[#out + 2] = math.floor(value / 16), value % 16 end while #out > count do table.remove(out) end return out end function RomExtractor:extractItems() self:beginStage("Items") local order = self.manifest.items local charmap = self.manifest.charmap local names = self:symbol("ItemNames") local prices = self:symbol("ItemPrices") local keyFlags = self:symbol("KeyItemFlags") local tmPrices = self:symbol("TechnicalMachinePrices") local decodedNames, address = {}, names.address for _ = 1, #order do local value, consumed = self.rom:readString( names.bank, address, charmap, 0x50, 32) decodedNames[#decodedNames + 1] = value address = address + consumed end local numItems = self.manifest.numItems local flags = self.rom:bytes( keyFlags.bank, keyFlags.address, math.floor((numItems + 7) / 8)) local out = {} for index, itemId in ipairs(order) do local entry = { id = itemId, index = index, name = decodedNames[index], price = Rom.bcd(self.rom:bytes( prices.bank, prices.address + (index - 1) * 3, 3)), source = ("ROM:ItemNames[%d]"):format(index), } if index <= numItems and bit.band(flags[math.floor((index - 1) / 8) + 1], 2 ^ ((index - 1) % 8)) ~= 0 then entry.keyItem = true end out[itemId] = entry end for number, move in ipairs(self.manifest.hms) do local itemId = "HM_" .. move out[itemId] = { id = itemId, name = ("HM%02d"):format(number), price = 0, machine = { kind = "HM", number = number, move = move }, source = "ROM metadata manifest (HM mapping)", } end local packed = self.rom:bytes(tmPrices.bank, tmPrices.address, math.floor((#self.manifest.tms + 1) / 2)) local pricesByTm = self:nybbles(packed, #self.manifest.tms) for number, move in ipairs(self.manifest.tms) do local itemId = "TM_" .. move out[itemId] = { id = itemId, name = ("TM%02d"):format(number), price = pricesByTm[number] * 1000, machine = { kind = "TM", number = number, move = move }, source = ("ROM:TechnicalMachinePrices[%d]"):format(number), } end self:write("items", out) self:tick("Items", 1, 1) return out end function RomExtractor:extractTypeChart() self:beginStage("Types") local types = {} for name, value in pairs(self.manifest.constants.types) do types[value] = name end local effects = self:symbol("TypeEffects") local address, matchups = effects.address, {} while self.rom:byte(effects.bank, address) ~= 0xFF do local row = self.rom:bytes(effects.bank, address, 3) matchups[#matchups + 1] = { attacker = types[row[1]] or hex("TYPE", row[1]), defender = types[row[2]] or hex("TYPE", row[2]), multiplier = row[3], } address = address + 3 end local names, seen = {}, {} for _, label in ipairs(self.manifest.typeNameLabels) do local symbol = self:symbol(label) local location = symbol.bank .. ":" .. symbol.address if not seen[location] then seen[location] = true names[#names + 1] = self.rom:readString( symbol.bank, symbol.address, self.manifest.charmap, 0x50, 16) end end local data = { source = "ROM:TypeEffects + TypeNames", matchups = matchups, names = names, } self:write("type_chart", data) self:tick("Types", 1, 1) return data end function RomExtractor:extractPalettes() self:beginStage("Color palettes") local order = self.manifest.paletteOrder local paletteTable = self:symbol("SuperPalettes") local function scale5(value) return round(value * 255 / 31) end local function readTable(symbol, names) local out = {} for index, name in ipairs(names) do local colors = {} for color = 0, 3 do local value = self.rom:word(symbol.bank, symbol.address + (index - 1) * 8 + color * 2) colors[#colors + 1] = { scale5(bit.band(value, 0x1F)), scale5(bit.band(bit.rshift(value, 5), 0x1F)), scale5(bit.band(bit.rshift(value, 10), 0x1F)), } end out[name] = colors end return out end local palettes = readTable(paletteTable, order) local monsterTable = self:symbol("MonsterPalettes") local monsterPalettes = {} for index, species in ipairs(self.manifest.dexOrder) do local paletteId = self.rom:byte( monsterTable.bank, monsterTable.address + index) monsterPalettes[species] = order[paletteId + 1] end local data = { source = "ROM:SuperPalettes + MonsterPalettes", palettes = palettes, order = order, pokemon = monsterPalettes, } -- Yellow (and GBC carts) also carry CGBBasePalettes beside SuperPalettes. if self.symbols["CGBBasePalettes"] then data.cgbBase = readTable(self:symbol("CGBBasePalettes"), order) data.source = data.source .. " + CGBBasePalettes" end self:write("palettes", data) self:tick("Color palettes", 1, 1) return data end function RomExtractor:extractIcons() self:beginStage("Party icons") local iconTable = self:symbol("MonPartyData") local count = #self.manifest.dexOrder local packed = self.rom:bytes(iconTable.bank, iconTable.address, math.floor((count + 1) / 2)) local values = self:nybbles(packed, count) local byDex = {} for _, value in ipairs(values) do byDex[#byDex + 1] = self.manifest.iconOrder[value + 1] or ("ICON_%X"):format(value) end local icons = { MON = "assets/generated/sprites/monster.png", BALL = "assets/generated/sprites/poke_ball.png", HELIX = "assets/generated/sprites/fossil.png", FAIRY = "assets/generated/sprites/fairy.png", BIRD = "assets/generated/sprites/bird.png", WATER = "assets/generated/sprites/seel.png", BUG = "assets/generated/icons/bug.png", GRASS = "assets/generated/icons/plant.png", SNAKE = "assets/generated/icons/snake.png", QUADRUPED = "assets/generated/icons/quadruped.png", -- Yellow's ICON_PIKACHU draws from the overworld PikachuSprite sheet -- (data/icon_pointers.asm mon_icon_header PikachuSprite, 0/12); -- only referenced when the manifest's iconOrder includes it PIKACHU = "assets/generated/sprites/pikachu.png", } local frames = { { "bug", "BugIconFrame1", "BugIconFrame2" }, { "plant", "PlantIconFrame1", "PlantIconFrame2" }, { "snake", "SnakeIconFrame1", "SnakeIconFrame2" }, { "quadruped", "QuadrupedIconFrame1", "QuadrupedIconFrame2" }, } for index, spec in ipairs(frames) do local raw = {} for labelIndex = 2, 3 do local symbol = self:symbol(spec[labelIndex]) append(raw, self.rom:bytes(symbol.bank, symbol.address, 32)) end local half = ImageWriter.decode2bpp(raw, 8, 32, true) local image = ImageWriter.blank(16, 32, 1, 1, 1, 0) for frame = 0, 1 do ImageWriter.blit(image, half, 0, frame * 16, 0, frame * 16, 8, 16) ImageWriter.blit(image, half, 8, frame * 16, 0, frame * 16, 8, 16, true) end self:save(image, "icons/" .. spec[1] .. ".png") self:tick("Party icons", index, #frames) end local data = { source = "ROM:MonPartyData", byDex = byDex, icons = icons } self:write("icons", data) return data end function RomExtractor:species(value) local order = self.manifest.constants.speciesOrder if value < 1 or value > #order then return hex("SPECIES", value) end return order[value] end function RomExtractor:item(value) local order = self.manifest.items if value < 1 or value > #order then return hex("ITEM", value) end return order[value] end function RomExtractor:move(value) if value == 0 then return nil end local order = self.manifest.constants.moveOrder if value < 1 or value > #order then return hex("MOVE", value) end return order[value] end function RomExtractor:typesById() local result = {} for name, value in pairs(self.manifest.constants.types) do result[value] = name end return result end function RomExtractor:decodeEvolutionsAndMoves(index) local pointerTable = self:symbol("EvosMovesPointerTable") local address = self.rom:word( pointerTable.bank, pointerTable.address + (index - 1) * 2) local evolutions = {} while true do local method = self.rom:byte(pointerTable.bank, address) address = address + 1 if method == 0 then break end if method == 1 then local row = self.rom:bytes(pointerTable.bank, address, 2) address = address + 2 evolutions[#evolutions + 1] = { method = "LEVEL", level = row[1], species = self:species(row[2]), } elseif method == 2 then local row = self.rom:bytes(pointerTable.bank, address, 3) address = address + 3 evolutions[#evolutions + 1] = { method = "ITEM", item = self:item(row[1]), level = row[2], species = self:species(row[3]), } elseif method == 3 then local row = self.rom:bytes(pointerTable.bank, address, 2) address = address + 2 evolutions[#evolutions + 1] = { method = "TRADE", level = row[1], species = self:species(row[2]), } else error(("unknown evolution method %d for species index %d") :format(method, index)) end end local learnset = {} while true do local level = self.rom:byte(pointerTable.bank, address) address = address + 1 if level == 0 then break end local move = self.rom:byte(pointerTable.bank, address) address = address + 1 learnset[#learnset + 1] = { level = level, move = self:move(move) } end return evolutions, learnset end function RomExtractor:dexEntry(index, species) local pointerTable = self:symbol("PokedexEntryPointers") local address = self.rom:word( pointerTable.bank, pointerTable.address + (index - 1) * 2) local kind, consumed = self.rom:readString( pointerTable.bank, address, self.manifest.charmap, 0x50, 32) address = address + consumed local heightFt = self.rom:byte(pointerTable.bank, address) local heightIn = self.rom:byte(pointerTable.bank, address + 1) local weight = self.rom:word(pointerTable.bank, address + 2) address = address + 4 assert(self.rom:byte(pointerTable.bank, address) == 0x17, "dex entry " .. index .. " has no TX_FAR command") local textAddress = self.rom:word(pointerTable.bank, address + 1) local textBank = self.rom:byte(pointerTable.bank, address + 3) local textLabel = self.manifest.dexEntryLabels[species] or ("_DexEntry_%02X_%04X"):format(textBank, textAddress) return { kind = kind, heightFt = heightFt, heightIn = heightIn, weight = weight, text = textLabel, } end function RomExtractor:extractPokemon() self:beginStage("Pokemon") local speciesOrder = self.manifest.constants.speciesOrder local dexBySpecies = {} for index, species in ipairs(self.manifest.dexOrder) do dexBySpecies[species] = index end local typeById = self:typesById() local names = self:symbol("MonsterNames") local baseStats = self:symbol("BaseStats") -- Red/Blue keep Mew outside BaseStats (pret pokered MewBaseStats). -- Yellow stores Mew as dex 151 inside BaseStats (pret/pokeyellow). local mewStats = self.symbols["MewBaseStats"] and self:symbol("MewBaseStats") local decodedNames = {} for index = 1, #speciesOrder do decodedNames[index] = self.rom:decodeText( self.rom:bytes(names.bank, names.address + (index - 1) * 10, 10), self.manifest.charmap) end local out, writtenFront, writtenBack = {}, {}, {} local completed = 0 for index, species in ipairs(speciesOrder) do local skip = startsWith(species, "MISSINGNO") or startsWith(species, "UNUSED") or startsWith(species, "FOSSIL_") or startsWith(species, "MON_GHOST") if not skip then local dex = assert(dexBySpecies[species], "missing dex number for " .. species) local row if species == "MEW" and mewStats then row = self.rom:bytes(mewStats.bank, mewStats.address, 28) else row = self.rom:bytes( baseStats.bank, baseStats.address + (dex - 1) * 28, 28) end assert(row[1] == dex, species .. ": base stats dex mismatch") local level1Moves = {} for position = 16, 19 do if row[position] ~= 0 then level1Moves[#level1Moves + 1] = self:move(row[position]) end end local tmhm = {} for moveIndex, move in ipairs(self.manifest.tmhmMoves) do local byte = row[21 + math.floor((moveIndex - 1) / 8)] if bit.band(byte, 2 ^ ((moveIndex - 1) % 8)) ~= 0 then tmhm[#tmhm + 1] = move end end local evolutions, learnset = self:decodeEvolutionsAndMoves(index) local asset = self.manifest.pokemonAssets[species] local front, back = asset.front, asset.back if front and not writtenFront[front] then local size = self:writeCompressedPic( asset.frontLabel, "battle/front/" .. front .. ".png") assert(size == math.floor(row[11] / 16), species .. ": front picture size mismatch") writtenFront[front] = true end if back and not writtenBack[back] then self:writeCompressedPic( asset.backLabel, "battle/back/" .. back .. ".png") writtenBack[back] = true end local speciesTypes = unique({ typeById[row[7]] or hex("TYPE", row[7]), typeById[row[8]] or hex("TYPE", row[8]), }) out[species] = { id = species, index = index, dex = dex, name = decodedNames[index], source = ("ROM:BaseStats[%d]"):format(dex), types = speciesTypes, baseStats = { hp = row[2], attack = row[3], defense = row[4], speed = row[5], special = row[6], }, catchRate = row[9], baseExp = row[10], level1Moves = level1Moves, growthRate = self.manifest.growthRates[row[20] + 1], tmhm = tmhm, learnset = learnset, evolutions = evolutions, spriteFront = front and "assets/generated/battle/front/" .. front .. ".png" or nil, spriteBack = back and "assets/generated/battle/back/" .. back .. ".png" or nil, frontSize = math.floor(row[11] / 16), dexEntry = self:dexEntry(index, species), } completed = completed + 1 self:tick("Pokemon", completed, #self.manifest.dexOrder + 10) end end for _, spec in ipairs({ { "FossilAerodactylPic", "fossilaerodactyl" }, { "FossilKabutopsPic", "fossilkabutops" }, { "GhostPic", "ghost" }, }) do self:writeCompressedPic(spec[1], "battle/front/" .. spec[2] .. ".png") completed = completed + 1 self:tick("Pokemon", completed, #self.manifest.dexOrder + 10) end for _, spec in ipairs({ { "RedPicBack", "redb" }, { "OldManPicBack", "oldmanb" }, }) do self:writeCompressedPic(spec[1], "battle/" .. spec[2] .. ".png") completed = completed + 1 self:tick("Pokemon", completed, #self.manifest.dexOrder + 10) end local balls = self:symbol("PokeballTileGraphics") self:write2bpp(self.rom:bytes(balls.bank, balls.address, 64), 32, 8, "battle/balls.png", true) completed = completed + 1 self:tick("Pokemon", completed, #self.manifest.dexOrder + 10) for _, spec in ipairs({ { "TrainerInfoTextBoxTileGraphics", "trainer_info.png", 24, 24, false }, { "GymLeaderFaceAndBadgeTileGraphics", "badges.png", 16, 256, true }, { "BadgeNumbersTileGraphics", "badge_numbers.png", 16, 32, true }, { "CircleTile", "circle_tile.png", 8, 8, true }, }) do local symbol = self:symbol(spec[1]) self:write2bpp( self.rom:bytes(symbol.bank, symbol.address, spec[3] * spec[4] / 4), spec[3], spec[4], "trainer_card/" .. spec[2], spec[5]) completed = completed + 1 self:tick("Pokemon", completed, #self.manifest.dexOrder + 10) end self:writeCompressedPic("RedPicFront", "trainer_card/red.png") self:write("pokemon", out) self:tick("Pokemon", #self.manifest.dexOrder + 10, #self.manifest.dexOrder + 10) return out end function RomExtractor:trainerParties(bank, startAddress, endAddress) local parties, address = {}, startAddress while address < endAddress do local first = self.rom:byte(bank, address) address = address + 1 local party = {} if first == 0xFF then while true do local level = self.rom:byte(bank, address) address = address + 1 if level == 0 then break end local species = self.rom:byte(bank, address) address = address + 1 party[#party + 1] = { level = level, species = self:species(species), } end else while true do local species = self.rom:byte(bank, address) address = address + 1 if species == 0 then break end party[#party + 1] = { level = first, species = self:species(species), } end end parties[#parties + 1] = party end assert(address == endAddress, ("trainer party data overran %02X:%04X"):format(bank, endAddress)) return parties end function RomExtractor:extractTrainers() self:beginStage("Trainers") local order = self.manifest.trainers local names = self:symbol("TrainerNames") local pointers = self:symbol("TrainerDataPointers") local money = self:symbol("TrainerPicAndMoneyPointers") local choices = self:symbol("TrainerClassMoveChoiceModifications") local decodedNames, address = {}, names.address for _ = 1, #order do local name, consumed = self.rom:readString( names.bank, address, self.manifest.charmap, 0x50, 32) decodedNames[#decodedNames + 1] = name address = address + consumed end local aiMods = {} address = choices.address for _ = 1, #order do local mods = {} while true do local value = self.rom:byte(choices.bank, address) address = address + 1 if value == 0 then break end mods[#mods + 1] = value end aiMods[#aiMods + 1] = mods end local partyStarts = {} for index = 0, #order - 1 do partyStarts[#partyStarts + 1] = self.rom:word( pointers.bank, pointers.address + index * 2) end local partyEnds = {} for index = 2, #partyStarts do partyEnds[#partyEnds + 1] = partyStarts[index] end partyEnds[#partyEnds + 1] = self:symbol("TrainerAI").address local out, written = {}, {} for index, label in ipairs(order) do local trainerId = "OPP_" .. label local rawMoney = self.rom:bytes( money.bank, money.address + (index - 1) * 5 + 2, 3) local picture = self.manifest.trainerPics[index] if picture and not written[picture.imageBase] then self:writeCompressedPic(picture.label, "battle/trainers/" .. picture.imageBase .. ".png") written[picture.imageBase] = true end local parties = self:trainerParties( pointers.bank, partyStarts[index], partyEnds[index]) -- ChiefData is empty in the ROM (the Celadon Chief battle is unused/ -- cut content); tools/rom_manifest.json carries a hand-authored party -- for the trainers this project reimplements, since no ROM data exists -- to extract for them. if #parties == 0 then local override = self.manifest.trainerPartyOverrides and self.manifest.trainerPartyOverrides[trainerId] if override then parties = { copy(override) } end end out[trainerId] = { id = trainerId, index = index, name = decodedNames[index], source = "ROM:TrainerDataPointers", pic = picture and picture.path or nil, baseMoney = math.floor(Rom.bcd(rawMoney) / 100), aiMods = aiMods[index], parties = parties, } self:tick("Trainers", index, #order) end -- Yellow's Jessie & James fight as OPP_ROCKET but carry their own pic -- (home/trainers2.asm IsFightingJessieJames); only Yellow's manifest has -- the symbol, so Red and Blue skip this (#439). if self.symbols.JessieJamesPic then local relative = "battle/trainers/jessie_james.png" self:writeCompressedPic("JessieJamesPic", relative) if out.OPP_ROCKET then out.OPP_ROCKET.picJessieJames = "assets/generated/" .. relative end end self:write("trainers", out) return out end function RomExtractor:wildTable(bank, address) local grassRate = self.rom:byte(bank, address) address = address + 1 local grass = { rate = grassRate, slots = {} } if grassRate ~= 0 then for _ = 1, 10 do local row = self.rom:bytes(bank, address, 2) address = address + 2 grass.slots[#grass.slots + 1] = { level = row[1], species = self:species(row[2]), } end end local waterRate = self.rom:byte(bank, address) address = address + 1 local water = { rate = waterRate, slots = {} } if waterRate ~= 0 then for _ = 1, 10 do local row = self.rom:bytes(bank, address, 2) address = address + 2 water.slots[#water.slots + 1] = { level = row[1], species = self:species(row[2]), } end end return grass, water end function RomExtractor:extractEncounters() self:beginStage("Wild Pokemon") local maps = self.manifest.constants.mapOrder local pointers = self:symbol("WildDataPointers") local nothing = self:symbol("NothingWildMons") local out = {} for index, mapId in ipairs(maps) do local address = self.rom:word( pointers.bank, pointers.address + (index - 1) * 2) if address ~= nothing.address then local grass, water = self:wildTable(pointers.bank, address) local entry = { source = ("ROM:%02X:%04X"):format(pointers.bank, address), } if grass.rate ~= 0 or #grass.slots > 0 then entry.grass = grass end if water.rate ~= 0 or #water.slots > 0 then entry.water = water end out[mapId] = entry end self:tick("Wild Pokemon", index, #maps) end self:write("encounters", out) return out end local TEXT_GLYPH_OVERRIDES = { [0x4B] = "{_CONT}", [0x4C] = "{SCROLL}", [0x6D] = "{COLON}", [0xF0] = "¥", } function RomExtractor:textGlyph(value) if TEXT_GLYPH_OVERRIDES[value] then return TEXT_GLYPH_OVERRIDES[value] end local glyph = self.manifest.charmap[tostring(value)] or ("{BYTE:%02X}"):format(value) if glyph:sub(1, 1) == "<" and glyph:sub(-1) == ">" then return "{" .. glyph:sub(2, -2) .. "}" end return glyph end function RomExtractor:decodeTextCommands(symbol, substitutions) local address = symbol.address local pending = 1 local out = {} for _ = 1, 4096 do local command = self.rom:byte(symbol.bank, address) address = address + 1 if command == 0x50 then assert(pending > #substitutions, symbol.name .. ": unused dynamic text substitutions") return table.concat(out) elseif command == 0 then while true do local value = self.rom:byte(symbol.bank, address) address = address + 1 if value == 0x50 then break end if value == 0x57 or value == 0x58 or value == 0x5F then assert(pending > #substitutions, symbol.name .. ": unused dynamic text substitutions") return table.concat(out) end out[#out + 1] = self:textGlyph(value) end elseif command == 1 or command == 2 or command == 9 then local expected = substitutions[pending] assert(expected, symbol.name .. ": missing dynamic text substitution") assert(command == expected[1], symbol.name .. ": dynamic text command mismatch") out[#out + 1] = expected[2] pending = pending + 1 address = address + (command == 1 and 2 or 3) else error(("%s: unsupported text command $%02X") :format(symbol.name, command)) end end error(symbol.name .. ": text command stream is too long") end function RomExtractor:extractText() self:beginStage("Dialogue") local metadata = self.manifest.text local texts = {} for index, label in ipairs(metadata.labels) do texts[label] = self:decodeTextCommands( self:symbol(label), metadata.dynamic[label] or {}) self:tick("Dialogue", index, #metadata.labels) end local trainerHeaders = {} for mapLabel, headers in pairs(metadata.trainerHeaders) do local converted = {} for index, header in pairs(headers) do converted[tonumber(index)] = header end trainerHeaders[mapLabel] = converted end self:write("text", texts) self:write("text_pointers", metadata.pointers) self:write("trainer_headers", trainerHeaders) return { texts = texts, pointers = metadata.pointers, trainerHeaders = trainerHeaders, } end function RomExtractor:extractYellowTitleArt() -- pret/pokeyellow engine/movie/title_yellow.asm: the Yellow title is a -- tilemap composition over BOTH tile banks. LoadYellowTitleScreenGFX -- loads PokemonLogoGraphics into vChars2 (BG ids $00-$7F), -- TitlePikachuBGGraphics into vChars1 (ids $80-$EF), -- TitlePikachuOBGraphics at vChars1 tile $70 (ids $F0-$FC, also the eye -- OAM tiles), and PokemonLogoCornerGraphics at vChars1 tile $7D (ids -- $FD-$FF). Every tilemap mixes ids from several of those sheets, so a -- single-sheet lookup shows checkerboard garbage where a foreign-bank id -- lands (e.g. blank id $00 = logo tile 0, not Pikachu BG tile 0). if not self.symbols["TitlePikachuBGGraphics"] then return end -- raw sheets, kept for debugging / mod reference self:raw2bpp("TitlePikachuBGGraphics", 128, 32, "title/pikachu_bg.png", { transparent = true }) self:raw2bpp("TitlePikachuOBGraphics", 96, 8, "title/pikachu_ob.png", { transparent = true }) -- Tile counts are the Graphics..GraphicsEnd symbol gaps in pokeyellow.sym. local function sheetTiles(label, count, transparent) local symbol = self:symbol(label) local raw = self.rom:bytes(symbol.bank, symbol.address, count * 16) local tiles = {} for offset = 1, #raw, 16 do local one = {} for i = offset, offset + 15 do one[#one + 1] = raw[i] end tiles[#tiles + 1] = ImageWriter.decode2bpp(one, 8, 8, transparent) end return tiles end local logo = sheetTiles("PokemonLogoGraphics", 115) local corner = sheetTiles("PokemonLogoCornerGraphics", 3) local bg = sheetTiles("TitlePikachuBGGraphics", 64) local ob = sheetTiles("TitlePikachuOBGraphics", 12) local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true) local function tileFor(id) if id < 0x80 then return logo[id + 1] end if id < 0xF0 then return bg[id - 0x80 + 1] end if id < 0xFD then return ob[id - 0xF0 + 1] end return corner[id - 0xFD + 1] end -- OAM-style blit: color-0 pixels stay whatever the target already holds -- (ImageWriter.blit copies alpha-0 pixels wholesale, which would punch -- holes into the face under the eye sprites). local function blitSprite(target, tile, tx, ty, flipX) for y = 0, 7 do for x = 0, 7 do local sx = flipX and 7 - x or x local r, g, b, a = tile:getPixel(sx, y) if a ~= 0 then target:setPixel(tx + x, ty + y, r, g, b, a) end end end end -- cells = { {id, col, row}, ... }; untouched cells stay transparent local function compose(cols, rows, cells) local pose = ImageWriter.blank(cols * 8, rows * 8, 1, 1, 1, 0) for _, cell in ipairs(cells) do local tile = tileFor(cell[1]) if tile then ImageWriter.blit(pose, tile, cell[2] * 8, cell[3] * 8) end end return pose end local function mapCells(map, cols, rows) local ids = self.rom:bytes(map.bank, map.address, cols * rows) local cells = {} for index, id in ipairs(ids) do cells[#cells + 1] = { id, (index - 1) % cols, math.floor((index - 1) / cols) } end return cells end -- TitleScreen_PlacePokemonLogo: 16x7 box at (2,1). Yellow's logo sheet -- is deduplicated (unlike Red's sequential rip), so the raw2bpp -- pokemon_logo.png from extractField is scrambled; overwrite it with the -- tilemap composition. Kept opaque: TitleState clears to white behind it. self:save(compose(16, 7, mapCells(self:symbol("TitleScreenPokemonLogoTilemap"), 16, 7)), "title/pokemon_logo.png") -- TitleScreen_PlacePikaSpeechBubble: 7x4 box at (6,4) plus the two tail -- tiles $64/$65 the routine pokes at (9,8) -- one row below the box, over -- blank cells of the Pikachu row. Composed 7x5 with the tail at (3,4); -- matteColor0 clears the outside-the-balloon whites, the outline protects -- the interior. local bubbleCells = mapCells( self:symbol("TitleScreenPikaBubbleTilemap"), 7, 4) bubbleCells[#bubbleCells + 1] = { 0x64, 3, 4 } bubbleCells[#bubbleCells + 1] = { 0x65, 4, 4 } self:save(ImageWriter.matteColor0(compose(7, 5, bubbleCells)), "title/pika_bubble.png") -- TitleScreen_PlacePikachu: 12x9 box at (4,8) plus the right-ear edge -- tiles it pokes down column 16 (rows 10-13) -- composed 13x9 with those -- at relative column 12, rows 2-5. The open eyes are OAM -- (TitleScreenPikachuEyesOAMData, copied at place time): OB tiles 0-3 at -- screen (56,80)/(88,80) blocks, the left eye x-flipped (attr $22); baked -- into the composition relative to the box origin px(32,64). local pikaCells = mapCells(self:symbol("TitleScreenPikachuTilemap"), 12, 9) pikaCells[#pikaCells + 1] = { 0x96, 12, 2 } pikaCells[#pikaCells + 1] = { 0x9d, 12, 3 } pikaCells[#pikaCells + 1] = { 0xa7, 12, 4 } pikaCells[#pikaCells + 1] = { 0xb1, 12, 5 } local pikachu = ImageWriter.matteColor0(compose(13, 9, pikaCells)) -- DoTitleScreenFunction's blink rewrites the eye OAM tile ids with -- `and $f3 / or e` (e = 0 open / 4 half / 8 closed), so the OB sheet -- holds three 4-tile eye sets. Bake the open set into pikachu.png and -- save half/closed as standalone overlays for TitleState's blink. local EYE_LAYOUT = { { 2, 24, 16, true }, { 1, 32, 16, true }, { 4, 24, 24, true }, { 3, 32, 24, true }, { 1, 56, 16 }, { 2, 64, 16 }, { 3, 56, 24 }, { 4, 64, 24 }, } -- Blink overlays for the (24,16)-(71,31) eye band: the BG face is -- eyeless (the eyes are OAM), so each overlay = the blank-face crop -- with the half (+4) / closed (+8) tile set composited color-0 -- transparent -- exactly what the hardware shows mid-blink. local overlays = {} for suffix, base in pairs({ eyes_half = 4, eyes_closed = 8 }) do local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0) ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16) for _, e in ipairs(EYE_LAYOUT) do blitSprite(overlay, obClear[base + e[1]], e[2] - 24, e[3] - 16, e[4]) end overlays[suffix] = overlay end -- open eyes bake into pikachu.png AFTER the blank-face crops for _, e in ipairs(EYE_LAYOUT) do blitSprite(pikachu, obClear[e[1]], e[2], e[3], e[4]) end self:save(pikachu, "title/pikachu.png") for suffix, overlay in pairs(overlays) do self:save(overlay, "title/" .. suffix .. ".png") end end function RomExtractor:raw2bpp(label, width, height, relative, options) options = options or {} local expected = width * height / 4 local length = options.storedLength or expected local symbol = self:symbol(label) local raw = self.rom:bytes(symbol.bank, symbol.address, length) while #raw < expected do raw[#raw + 1] = 0 end if options.columns then raw = ImageWriter.columnsToRows(raw, width / 8, height / 8) end local image = ImageWriter.decode2bpp( raw, width, height, options.transparent) if options.matte then image = ImageWriter.matteColor0(image) end self:save(image, relative) return image end function RomExtractor:raw1bpp(label, width, height, relative, transparent) local symbol = self:symbol(label) local raw = self.rom:bytes( symbol.bank, symbol.address, width * height / 8) local image = ImageWriter.decode1bpp(raw, width, height, transparent) self:save(image, relative) return image end function RomExtractor:extractField() self:beginStage("Interface artwork") local done, total = 0, 49 local function tick() done = done + 1 self:tick("Interface artwork", math.min(done, total), total) end self:raw2bpp("PokemonLogoGraphics", 128, 56, "title/pokemon_logo.png"); tick() self:raw1bpp("Version_GFX", 80, 8, "title/red_version.png"); tick() self:raw2bpp("PlayerCharacterTitleGraphics", 40, 56, "title/player.png", { matte = true }); tick() self:raw2bpp("NintendoCopyrightLogoGraphics", 152, 8, "title/copyright.png"); tick() self:raw2bpp("GameFreakLogoGraphics", 72, 8, "title/gamefreak_inc.png"); tick() -- Yellow fixed Pikachu title art (no-op on Red/Blue manifests). self:extractYellowTitleArt(); tick() local fallingStar = self:raw2bpp( "FallingStar", 8, 8, "intro/falling_star.png", { transparent = true }) tick() local blink = ImageWriter.blank(8, 8, 1, 1, 1, 0) for y = 0, 7 do for x = 0, 7 do local r, g, b, a = fallingStar:getPixel(x, y) if a ~= 0 and math.abs(r - 2 / 3) < 0.001 then blink:setPixel(x, y, r, g, b, a) end end end self:save(blink, "intro/falling_star_blink.png"); tick() local gameFreak = self:symbol("GameFreakIntro") local presentsLength = 104 * 8 / 4 local presents = ImageWriter.decode2bpp( self.rom:bytes(gameFreak.bank, gameFreak.address, presentsLength), 104, 8, true) self:save(presents, "intro/gamefreak_presents.png"); tick() self:save(ImageWriter.decode2bpp( self.rom:bytes(gameFreak.bank, gameFreak.address + presentsLength, 16 * 24 / 4), 16, 24, true), "intro/gamefreak_logo.png"); tick() local textImage = ImageWriter.blank(80, 8, 1, 1, 1, 0) local textTiles = { 0, 1, 2, 3, false, 4, 5, 3, 1, 6 } for index, tile in ipairs(textTiles) do if tile then ImageWriter.blit(textImage, presents, (index - 1) * 8, 0, tile * 8, 0, 8, 8) end end self:save(textImage, "intro/gamefreak_text.png"); tick() local moveTiles = self:symbol("MoveAnimationTiles1") local star = ImageWriter.blank(16, 16, 1, 1, 1, 0) for _, spec in ipairs({ { 0, 3 }, { 1, 19 } }) do local tile = ImageWriter.decode2bpp(self.rom:bytes( moveTiles.bank, moveTiles.address + spec[2] * 16, 16), 8, 8, true) ImageWriter.blit(star, tile, 0, spec[1] * 8) ImageWriter.blit(star, tile, 8, spec[1] * 8, 0, 0, 8, 8, true) end self:save(star, "intro/big_star.png"); tick() -- Yellow has no FightIntro Gengar/Nidorino fight (pret/pokeyellow -- engine/movie/intro_yellow.asm); write blank placeholders so Title/ -- Intro still find the expected paths. Red/Blue keep the tilemap rip. if self.symbols["FightIntroBackMon"] then local gengar = self:symbol("FightIntroBackMon") local gengarRaw = self.rom:bytes( gengar.bank, gengar.address, 96 * 16) local gengarTiles = {} for offset = 1, #gengarRaw, 16 do local raw = {} for index = offset, offset + 15 do raw[#raw + 1] = gengarRaw[index] end gengarTiles[#gengarTiles + 1] = ImageWriter.decode2bpp(raw, 8, 8) end for number = 1, 3 do local tilemap = self:symbol("GengarIntroTiles" .. number) local tileIds = self.rom:bytes(tilemap.bank, tilemap.address, 49) local pose = ImageWriter.blank(56, 56, 0, 0, 0, 0) for index, tileId in ipairs(tileIds) do ImageWriter.blit(pose, gengarTiles[tileId + 1], (index - 1) % 7 * 8, math.floor((index - 1) / 7) * 8) end pose = ImageWriter.matteColor0(pose) self:save(pose, "intro/gengar_" .. number .. ".png"); tick() end else for number = 1, 3 do self:save(ImageWriter.blank(56, 56, 0, 0, 0, 0), "intro/gengar_" .. number .. ".png"); tick() end end if self.symbols["FightIntroFrontMon"] then for number, label in ipairs({ "FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3", }) do self:raw2bpp(label, 48, 48, "intro/red_nidorino_" .. number .. ".png", { transparent = true, columns = true }) tick() end else for number = 1, 3 do self:save(ImageWriter.blank(48, 48, 1, 1, 1, 0), "intro/red_nidorino_" .. number .. ".png"); tick() end end -- Optional Yellow-only intro atlas (pret/pokeyellow gfx/yellow_intro.asm). if self.symbols["YellowIntroGraphics1"] then self:raw2bpp("YellowIntroGraphics1", 128, 64, "intro/yellow_intro_1.png") end if self.symbols["YellowIntroGraphics2"] then -- atlas2 doubles as the intro's OBJ tile bank (vChars0); OBJ color 0 -- is hardware-transparent, and the BG draws it over a white clear so -- BG cells lose nothing self:raw2bpp("YellowIntroGraphics2", 128, 128, "intro/yellow_intro_2.png", { transparent = true }) end -- Yellow intro clouds (intro_yellow.asm YellowIntroCloudGFX): 8 tiles, -- two 4-tile animation frames -- saved 32x16, one frame per row. if self.symbols["YellowIntroCloudGFX"] then self:raw2bpp("YellowIntroCloudGFX", 32, 16, "intro/clouds.png") end for number = 1, 2 do self:writeCompressedPic( "ShrinkPic" .. number, "intro/shrink" .. number .. ".png") tick() end self:raw2bpp("SlotMachineTiles1", 128, 24, "slots/red_slots_1.png", { storedLength = 0x250 }); tick() local slotSheet = self:raw2bpp( "SlotMachineTiles2", 32, 48, "slots/red_slots_2.png") tick() local transparentSlots = ImageWriter.blank(32, 48, 1, 1, 1, 0) for y = 0, 47 do for x = 0, 31 do local r, g, b, a = slotSheet:getPixel(x, y) transparentSlots:setPixel(x, y, r, g, b, r == 1 and g == 1 and b == 1 and a == 1 and 0 or a) end end local slotOrder = self.manifest.field.slotSymbols.order local symbolSheet = ImageWriter.blank(#slotOrder * 16, 16, 1, 1, 1, 0) for index, name in ipairs(slotOrder) do local value = self.manifest.field.slotSymbols.symbols[name].tiles local high, low = math.floor(value / 0x100), value % 0x100 for _, row in ipairs({ { 0, high }, { 1, low } }) do local x, y = row[2] % 4 * 8, math.floor(row[2] / 4) * 8 ImageWriter.blit(symbolSheet, transparentSlots, (index - 1) * 16, row[1] * 8, x, y, 16, 8) end end self:save(symbolSheet, "slots/symbols.png"); tick() -- Emote sheet layout comes from manifest.field.emotionBubbles so the -- versions can differ: Red ships the three shared bubbles, Yellow adds -- the five Pikachu-only ones (emotion_bubbles.asm Skull/Heart/Bolt/ -- Zzz/FishEmote, used by the PikachuEmotionTable reactions). local EMOTE_SYMBOLS = { EXCLAMATION_BUBBLE = "ShockEmote", QUESTION_BUBBLE = "QuestionEmote", SMILE_BUBBLE = "HappyEmote", SKULL_BUBBLE = "SkullEmote", HEART_BUBBLE = "HeartEmote", BOLT_BUBBLE = "BoltEmote", ZZZ_BUBBLE = "ZzzEmote", FISH_BUBBLE = "FishEmote", } local bubbleDefs = self.manifest.field.emotionBubbles and self.manifest.field.emotionBubbles.bubbles local emoteLabels = {} for _, b in ipairs(bubbleDefs or {}) do emoteLabels[#emoteLabels + 1] = EMOTE_SYMBOLS[b.name] end if #emoteLabels == 0 then emoteLabels = { "ShockEmote", "QuestionEmote", "HappyEmote" } end local emotes = ImageWriter.blank(#emoteLabels * 16, 16, 1, 1, 1, 0) for index, label in ipairs(emoteLabels) do local symbol = self:symbol(label) local image = ImageWriter.decode2bpp( self.rom:bytes(symbol.bank, symbol.address, 64), 16, 16, true) ImageWriter.blit(emotes, image, (index - 1) * 16, 0) end self:save(emotes, "emotes.png"); tick() -- Yellow-only: the Surfing Pikachu minigame sheets -- (gfx/surfing_pikachu.asm) at pret's canvas widths, so -- src/ui/SurfingMinigame.lua's quads can be read off the source pngs. -- 1a is the BG set (water/beach/score tiles, opaque); 1b the OAM pose -- sheet and 1c the intro set (both color-0 transparent). for _, spec in ipairs({ { "SurfingPikachu1Graphics1", 65, 40, false, "minigame/surf_1a.png" }, { "SurfingPikachu1Graphics2", 256, 128, true, "minigame/surf_1b.png" }, { "SurfingPikachu1Graphics3", 144, 96, true, "minigame/surf_1c.png" }, }) do if self.symbols[spec[1]] then local symbol = self:symbol(spec[1]) local tilesPerRow = spec[3] / 8 local image = ImageWriter.decode2bpp( self.rom:bytes(symbol.bank, symbol.address, spec[2] * 16), spec[3], spec[2] / tilesPerRow * 8, spec[4]) self:save(image, spec[5]) end end self:raw1bpp("LedgeHoppingShadow", 8, 8, "fx/shadow.png", true); tick() for _, spec in ipairs({ { "RedFishingRodTiles", 8, 24, "fishing_rod.png" }, { "RedFishingTilesSide", 16, 8, "red_fish_side.png" }, { "RedFishingTilesFront", 16, 8, "red_fish_front.png" }, { "RedFishingTilesBack", 16, 8, "red_fish_back.png" }, { "PokeCenterFlashingMonitorAndHealBall", 8, 16, "heal_machine.png" }, { "SSAnneSmokePuffTile", 8, 8, "smoke.png" }, }) do self:raw2bpp(spec[1], spec[2], spec[3], "fx/" .. spec[4], { transparent = true }) tick() end -- The cuttable tree's own sprite: InitCutAnimOAM (engine/overworld/cut.asm) -- copies Overworld_GFX tiles $2d-$2e (top half) and $3d-$3e (bottom half) -- into the OAM tiles the Cut animation slides apart, so this comes out of -- the OVERWORLD tileset's own graphics blob rather than a named symbol. do local overworldIndex for i, name in ipairs(self.manifest.constants.tilesetOrder) do if name == "OVERWORLD" then overworldIndex = i break end end assert(overworldIndex, "OVERWORLD tileset not found in tilesetOrder") local tilesetHeaders = self:symbol("Tilesets") local rowAddress = tilesetHeaders.address + (overworldIndex - 1) * 12 local gfxBank = self.rom:byte(tilesetHeaders.bank, rowAddress) local gfxPointer = self.rom:word(tilesetHeaders.bank, rowAddress + 3) local cutTree = ImageWriter.blank(16, 16, 1, 1, 1, 0) for _, spec in ipairs({ { 0x2d, 0, 0 }, { 0x2e, 8, 0 }, { 0x3d, 0, 8 }, { 0x3e, 8, 8 }, }) do local tileIndex, dx, dy = spec[1], spec[2], spec[3] local tile = ImageWriter.decode2bpp( self.rom:bytes(gfxBank, gfxPointer + tileIndex * 16, 16), 8, 8, true) ImageWriter.blit(cutTree, tile, dx, dy) end self:save(cutTree, "fx/cut_tree.png"); tick() end self:raw2bpp("BattleTransitionTile", 8, 8, "fx/battle_transition.png"); tick() self:raw2bpp("PokedexTileGraphics", 24, 48, "fx/pokedex.png"); tick() self:raw2bpp("HpBarAndStatusGraphics", 120, 16, "battle/font_battle_extra.png", { transparent = true }); tick() for number, label in ipairs({ "BattleHudTiles1", "BattleHudTiles2", "BattleHudTiles3", }) do self:raw1bpp(label, 24, 8, "battle/battle_hud_" .. number .. ".png", true) tick() end local theEnd = self:symbol("TheEndGfx") local interleaved = self.rom:bytes( theEnd.bank, theEnd.address, 160) local reordered = {} for column = 0, 4 do for offset = 1, 16 do reordered[column * 16 + offset] = interleaved[column * 32 + offset] reordered[(column + 5) * 16 + offset] = interleaved[column * 32 + 16 + offset] end end self:save(ImageWriter.decode2bpp(reordered, 40, 16), "credits/the_end.png"); tick() self:raw2bpp("WorldMapTileGraphics", 32, 32, "townmap/tiles.png"); tick() self:raw1bpp("TownMapCursor", 16, 16, "townmap/cursor.png", true); tick() local data = copy(self.manifest.field) local adjacency = data.hiddenExtras.trashCans.adjacent local converted = {} for index, values in pairs(adjacency) do converted[tonumber(index)] = values end data.hiddenExtras.trashCans.adjacent = converted data.source = "canonical Pokemon Red ROM + bundled port metadata" self:write("field", data) self:tick("Interface artwork", total, total) return data end function RomExtractor:extractAudio() self:beginStage("Sound programs") local metadata = copy(self.manifest.audio) -- Yellow adds a fourth music bank ($20: Jessie & James, Surfing -- Pikachu, GB Printer); the manifest names the pack when it needs it. local bankOrder = metadata.programBanks or { 2, 8, 31 } local chunks = {} for index, bank in ipairs(bankOrder) do local first = Rom.offset(bank, 0x4000) + 1 chunks[index] = self.rom.data:sub(first, first + 0x3FFF) self:tick("Sound programs", index, #bankOrder + 2) end -- CacheFs (not love.filesystem directly) so a portable install lands this -- in the game folder with the rest of the cache; it creates the parent -- directory too. 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 songs = {} for name, header in pairs(metadata.musicHeaders) do songs[name] = header end metadata.pikaCries = self:extractPikachuCries() local cries = {} local cryData = metadata.cryData for index, species in ipairs(self.manifest.constants.speciesOrder) do local row = self.rom:bytes( cryData.bank, cryData.address + (index - 1) * 3, 3) if not startsWith(species, "MISSINGNO") and not startsWith(species, "UNUSED") then cries[species] = { header = metadata.cryHeaders[tostring(row[1])], pitch = row[2], length = row[3], } end end metadata.runtime = true metadata.programFile = "assets/generated/audio/programs.bin" metadata.bankOrder = bankOrder metadata.songs = songs metadata.sfx = metadata.sfxHeaders metadata.cries = cries metadata.source = "canonical Pokemon Red ROM sound programs" self:write("audio", metadata) self:tick("Sound programs", #bankOrder + 2, #bankOrder + 2) return metadata end -- Yellow's voiced Pikachu clips (audio/pikachu_cries_pointers.asm -- PikachuCriesPointerTable, 42 `dba` rows; each clip is `dw length` then -- 1-bit PCM, MSB first -- home/pikachu_cries.asm PlayPikachuPCM toggles -- rAUD3LEVEL per bit at roughly 190 CPU cycles a sample). Decoded to -- plain 8-bit mono WAVs; returns the clip count for data.audio.pikaCries, -- or nil when the manifest has no pointer table (Red/Blue). function RomExtractor:extractPikachuCries() if not self.symbols["PikachuCriesPointerTable"] then return nil end local NUM = 42 -- NUM_PIKA_CRIES local RATE = 22050 -- ~4.19 MHz / ~190 cycles per sample -- byte -> 8 samples, MSB first (LoadNextSoundClipSample: `and $80`) local lut = {} for byte = 0, 255 do local out = {} for bit = 7, 0, -1 do local on = math.floor(byte / 2 ^ bit) % 2 == 1 out[#out + 1] = string.char(on and 0xE0 or 0x20) end lut[byte] = table.concat(out) end local function u16(v) return string.char(v % 256, math.floor(v / 256) % 256) end local function u32(v) return string.char(v % 256, math.floor(v / 256) % 256, math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256) end local CacheFs = require("src.import.CacheFs") local pointers = self:symbol("PikachuCriesPointerTable") for index = 0, NUM - 1 do local row = self.rom:bytes(pointers.bank, pointers.address + index * 3, 3) local bank, address = row[1], row[2] + row[3] * 256 local header = self.rom:bytes(bank, address, 2) local length = header[1] + header[2] * 256 local raw = self.rom:bytes(bank, address + 2, length) local samples = {} for i, byte in ipairs(raw) do samples[i] = lut[byte] end local pcm = table.concat(samples) local wav = "RIFF" .. u32(36 + #pcm) .. "WAVEfmt " .. u32(16) .. u16(1) .. u16(1) .. u32(RATE) .. u32(RATE) .. u16(1) .. u16(8) .. "data" .. u32(#pcm) .. pcm local ok, err = CacheFs.write( ("assets/generated/audio/pika_cries/cry_%02d.wav"):format(index + 1), wav) if not ok then error("could not write pika cry " .. (index + 1) .. ": " .. tostring(err)) end end return NUM end function RomExtractor:run() local results = {} results.constants = self:extractConstants() results.tilesets = self:extractTilesets() results.maps = self:extractMaps() results.font = self:extractFont() results.sprites = self:extractSprites() results.moves = self:extractMoves() results.battle_anims = self:extractBattleAnimations() results.items = self:extractItems() results.type_chart = self:extractTypeChart() results.palettes = self:extractPalettes() results.icons = self:extractIcons() results.pokemon = self:extractPokemon() results.trainers = self:extractTrainers() results.encounters = self:extractEncounters() results.text = self:extractText() results.field = self:extractField() results.audio = self:extractAudio() if self.progress then self.progress(STAGE_COUNT, STAGE_COUNT, "Ready", 1, 1) end return results end return RomExtractor