Files
gen1recomp/src/import/RomExtractor.lua
T

2044 lines
75 KiB
Lua

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