mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-13 08:51:07 +02:00
2044 lines
75 KiB
Lua
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
|