Files
gen1recomp/src/import/gba/cli_extract.lua
T
1jamie c93f035359 fix(game3): hidden items, itemfinder, battle hud caught marker, moveset learnsets, and easy chat
- Hidden Items & Itemfinder (fixes #2314):
  * Extract BG_EVENT_HIDDEN_ITEM (kind 7) from GBA ROM with proper flag calculation (0x3E8 + id), underfoot flags, and quantities.
  * Implement overworld Itemfinder scan logic (7-tile radius, directional response, underfoot detection, SFX 65, and OEM text).
  * Wire up Bag Menu Itemfinder dispatch to overworld field runtime.

- Opponent Battle HUD Caught Marker (fixes #2313):
  * Add authentic Pokédex caught Poké Ball icon (B_INTERFACE_GFX_BALL_CAUGHT / tile 70) to opponent healthbox.
  * Match pokefirered parity in TryAddPokeballIconToHealthbox (wild encounters only, non-tutorial, non-ghost, checked against Dex).
  * Ensure status ailment icons (PSN, PAR, SLP, FRZ, BRN) take rendering priority over the caught ball marker.

- Level-Up Learnset & Movepool Assignment (fixes #2315):
  * Fix wild and generated Pokémon moveset assignment to assign the 4 most recent level-up moves up to the current level instead of getting stuck on basic moves (e.g. Tackle).

- Easy Chat / Profile Quick Chat:
  * Extract full word groups, categories, and dictionary entries from ROM.
  * Build Easy Chat interface with group selection, paginated word lists, and alphabetized filtering.
  * Fix dialog text overflowing when exiting profile / quick chat prompts.

- Nurse Joy & PC Storage / Map Polishing:
  * Implement Nurse Joy healing tray animation flow and audio cues in standard scripts.
  * Fix PC storage box header/wallpaper rendering and storage data extraction.
  * Correct Sevii Islands / Island 1 map section lookups and region map coordinate handling.
  * Fix circular dependency in battle residual handlers (partialTrap).

fixes #2325
fixes #2322
fixes #2315
fixes #2314
fixes #2313
2026-09-19 15:12:28 -05:00

513 lines
18 KiB
Lua

#!/usr/bin/env luajit
-- Offline FRLG extract → CacheFS (Love firered save dir), never mod-tree.
-- Usage (from repo root):
-- luajit src/import/gba/cli_extract.lua [path/to/firered.gba]
-- luajit src/import/gba/cli_extract.lua --native-only [rom]
-- Override output with GBA_CACHE_ROOT=/path/to/firered (contains data/generated/gba).
package.path = package.path
.. ";./?.lua;./?/init.lua"
.. ";mods/Kanto-Reforged/?.lua;mods/Kanto-Reforged/?/init.lua"
-- Allow running from inside the mod directory.
local function add_path(p)
package.path = p .. "/?.lua;" .. p .. "/?/init.lua;" .. package.path
end
add_path(".")
add_path("..")
add_path("../..")
add_path("../../..")
-- Map mods.Kanto-Reforged.* → local sevii when running inside mod tree.
local real_require = require
table.insert(package.searchers or package.loaders, 1, function(name)
local prefix = "mods.Kanto-Reforged."
if name:sub(1, #prefix) == prefix then
local rel = name:sub(#prefix + 1):gsub("%.", "/")
local candidates = {
rel .. ".lua",
"./" .. rel .. ".lua",
}
for _, c in ipairs(candidates) do
local f = io.open(c, "r")
if f then
f:close()
return assert(loadfile(c))
end
end
end
end)
local FileIO = require("src.import.gba.file_io")
local Extract = require("src.import.gba.extract_island1")
local nativeOnly = false
local pokemonOnly = false
local battleMovesOnly = false
local encountersOnly = false
local namingOnly = false
local introOnly = false
local battleAnimsOnly = false
local battleAiOnly = false
local battleTransitionsOnly = false
local trainersOnly = false
local itemsOnly = false
local pokedexOnly = false
local mapTreeOnly = false
local martsOnly = false
local scriptsOnly = false
local bagChromeOnly = false
local storageChromeOnly = false
local dumpMid = nil -- { pair, mid, outPath }
local romPath = nil
local ai = 1
while ai <= #arg do
if arg[ai] == "--native-only" then
nativeOnly = true
ai = ai + 1
elseif arg[ai] == "--pokemon" then
pokemonOnly = true
ai = ai + 1
elseif arg[ai] == "--items" then
itemsOnly = true
ai = ai + 1
elseif arg[ai] == "--pokedex" then
pokedexOnly = true
ai = ai + 1
elseif arg[ai] == "--marts" then
martsOnly = true
ai = ai + 1
elseif arg[ai] == "--scripts" then
scriptsOnly = true
ai = ai + 1
elseif arg[ai] == "--bag-chrome" then
bagChromeOnly = true
ai = ai + 1
elseif arg[ai] == "--storage-chrome" or arg[ai] == "--storage" then
storageChromeOnly = true
ai = ai + 1
elseif arg[ai] == "--map-tree" then
mapTreeOnly = true
ai = ai + 1
elseif arg[ai] == "--battle-moves" then
battleMovesOnly = true
ai = ai + 1
elseif arg[ai] == "--battle-anims" then
battleAnimsOnly = true
ai = ai + 1
elseif arg[ai] == "--battle-ai" then
battleAiOnly = true
ai = ai + 1
elseif arg[ai] == "--battle-transitions" then
battleTransitionsOnly = true
ai = ai + 1
elseif arg[ai] == "--trainers" then
trainersOnly = true
ai = ai + 1
elseif arg[ai] == "--encounters" then
encountersOnly = true
ai = ai + 1
elseif arg[ai] == "--naming" then
namingOnly = true
ai = ai + 1
elseif arg[ai] == "--intro" then
introOnly = true
ai = ai + 1
elseif arg[ai] == "--dump-mid" and arg[ai + 3] then
dumpMid = { pair = arg[ai + 1], mid = tonumber(arg[ai + 2]), out = arg[ai + 3] }
ai = ai + 4
elseif not romPath and arg[ai]:sub(1, 1) ~= "-" then
romPath = arg[ai]
ai = ai + 1
else
ai = ai + 1
end
end
if not romPath then
-- Default: FireRed dump in mod root (gitignored).
local here = debug.getinfo(1, "S").source:match("@(.*)/") or "."
romPath = here .. "/../../1636 - Pokemon Fire Red (U)(Squirrels).gba"
if not io.open(romPath, "rb") then
romPath = "1636 - Pokemon Fire Red (U)(Squirrels).gba"
end
end
local md5
do
-- Engine identity is SHA-1; fall back to md5sum (Versions.BY_MD5 aliases).
local q = "'" .. romPath:gsub("'", "'\\''") .. "'"
local p = io.popen("sha1sum " .. q .. " 2>/dev/null || shasum -a 1 " .. q)
local line = p and p:read("*l")
if p then p:close() end
md5 = line and line:match("^(%x+)")
if not md5 then
p = io.popen("md5sum " .. q)
line = p and p:read("*l")
if p then p:close() end
md5 = line and line:match("^(%x+)")
end
md5 = md5 or "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc"
end
print("ROM:", romPath)
print("SHA1:", md5)
local imports = FileIO.makeImports(romPath, md5, "firered")
-- CacheFS root: Love firered save dir (data/generated/gba lives underneath).
local outDir = os.getenv("GBA_CACHE_ROOT")
or ((os.getenv("HOME") or ".") .. "/.local/share/love/pokemon-love2d/firered")
Extract.CACHE_ROOT = "data/generated/gba"
Extract.NATIVE_ROOT = "data/generated/gba/native"
local cache = FileIO.makeCache(outDir)
print("CacheFS:", outDir .. "/" .. Extract.CACHE_ROOT)
if dumpMid then
local Rom = require("src.import.gba.rom")
local Versions = require("src.import.gba.versions")
local Tileset = require("src.import.gba.tileset")
local Metatile = require("src.import.gba.metatile")
local NativePack = require("src.import.gba.native_pack")
local version = assert(Versions.lookup(md5))
local rom = assert(Rom.open(imports, "firered"))
local bundle = assert(Tileset.loadPair(rom, version, dumpMid.pair))
local idx = Metatile.compositeIndexed(bundle, dumpMid.mid)
local rgb = NativePack.palsToRgb8(bundle.mapPals)
local fake = {
midCount = 1, atlasCols = 1, atlasRows = 1, midIds = { dumpMid.mid }, pixels = idx,
}
local rgba = NativePack.bakeRgba(fake, rgb)
local f = assert(io.open(dumpMid.out, "wb"))
f:write("P6\n16 16\n255\n")
for i = 1, 16 * 16 do
local o = (i - 1) * 4 + 1
f:write(string.char(rgba:byte(o), rgba:byte(o + 1), rgba:byte(o + 2)))
end
f:close()
print("Wrote", dumpMid.out)
imports:_close()
os.exit(0)
end
if namingOnly or introOnly then
print("NOTE: --naming/--intro require Love2D image encoders; use RomExtractorGen3 or love runner.")
print("Offsets live in Versions.NAMING / Versions.INTRO.")
os.exit(0)
end
if battleMovesOnly then
local Rom = require("src.import.gba.rom")
local BattleMovesExtract = require("src.import.gba.battle_moves_extract")
local outRoot = os.getenv("HOME")
and (os.getenv("HOME") .. "/.local/share/love/pokemon-love2d/firered")
or "."
local packCache = FileIO.makeCache(outRoot)
local rom = assert(Rom.open(imports, "firered"))
print("Extracting gBattleMoves →", outRoot .. "/data/generated/gba/pokemon")
local detail = BattleMovesExtract.run(rom, packCache, { cacheRoot = "data/generated/gba" })
rom:clearCache()
imports:_close()
print("OK battle moves", detail.pack.count, "→", detail.path)
os.exit(0)
end
if encountersOnly then
local Rom = require("src.import.gba.rom")
local Versions = require("src.import.gba.versions")
local EncExtract = require("src.import.gba.encounters_extract")
local outRoot = os.getenv("HOME")
and (os.getenv("HOME") .. "/.local/share/love/pokemon-love2d/firered")
or "."
local packCache = FileIO.makeCache(outRoot)
local version = assert(Versions.lookup(md5))
local rom = assert(Rom.open(imports, "firered"))
print("Extracting gWildMonHeaders →", outRoot .. "/data/generated/gba/encounters.lua")
local detail = assert(EncExtract.writeExtract(rom, packCache, "data/generated/gba", version))
rom:clearCache()
imports:_close()
print("OK encounters", detail.headers, "headers,", detail.aliases, "aliases")
os.exit(0)
end
if battleAnimsOnly then
local Rom = require("src.import.gba.rom")
local AnimExtract = require("src.import.gba.battle_anim_extract")
local outRoot = outDir
local packCache = cache
local rom = assert(Rom.open(imports, "firered"))
print("Extracting battle anim IR →", outRoot .. "/data/generated/gba/pokemon/battle_anims")
local animCache = {
write = function(_, rel, bytes)
local path = rel
if not path:match("^data/") then
path = "data/generated/gba/" .. path
end
return packCache:write(path, bytes)
end,
exists = function(_, rel) return packCache.exists and packCache:exists(rel) end,
read = function(_, rel) return packCache.read and packCache:read(rel) end,
}
local detail = assert(AnimExtract.run(rom, animCache, { cacheRoot = "data/generated/gba", force = true }))
rom:clearCache()
imports:_close()
print("OK battle anims", detail.moveCount, "→", detail.path)
os.exit(0)
end
if battleAiOnly then
local AiExtract = require("src.import.gba.battle_ai_extract")
local outRoot = os.getenv("HOME")
and (os.getenv("HOME") .. "/.local/share/love/pokemon-love2d/firered")
or "."
local packCache = FileIO.makeCache(outRoot)
print("Extracting battle AI scripts →", outRoot .. "/data/generated/gba/battle_ai")
local detail = assert(AiExtract.run({
cache = packCache,
cacheRoot = "data/generated/gba",
pretRoot = os.getenv("POKEFIRERED"),
}))
imports:_close()
print("OK battle AI", detail.scriptCount or "?", "scripts →", detail.path or detail.root)
os.exit(0)
end
if battleTransitionsOnly then
local Rom = require("src.import.gba.rom")
local BattleTransitionExtract = require("src.import.gba.battle_transition_extract")
local outRoot = os.getenv("HOME")
and (os.getenv("HOME") .. "/.local/share/love/pokemon-love2d/firered")
or "."
local packCache = FileIO.makeCache(outRoot)
local rom = assert(Rom.open(imports, "firered"))
print("Extracting battle transitions →", outRoot .. "/data/generated/gba/pokemon/battle_transition")
local detail = assert(BattleTransitionExtract.run(rom, packCache, {
cacheRoot = "data/generated/gba",
}))
rom:clearCache()
imports:_close()
print("OK battle transition →", detail.root,
detail.bigW .. "x" .. detail.bigH, "big_pokeball")
os.exit(0)
end
if trainersOnly then
local Rom = require("src.import.gba.rom")
local TrainerExtract = require("src.import.gba.trainer_extract")
local outRoot = os.getenv("HOME")
and (os.getenv("HOME") .. "/.local/share/love/pokemon-love2d/firered")
or "."
local packCache = FileIO.makeCache(outRoot)
local rom = assert(Rom.open(imports, "firered"))
print("Extracting trainers →", outRoot .. "/data/generated/gba/trainers.lua")
local detail = TrainerExtract.run(rom, packCache, { cacheRoot = "data/generated/gba" })
rom:clearCache()
imports:_close()
print("OK trainers", detail.pack.trainerCount, "→", detail.root)
os.exit(0)
end
if pokemonOnly then
local Rom = require("src.import.gba.rom")
local PokemonExtract = require("src.import.gba.pokemon_extract")
-- Standalone FireRed cache (never sevii). Prefer LOVE save dir when present.
local outRoot = os.getenv("HOME")
and (os.getenv("HOME") .. "/.local/share/love/pokemon-love2d/firered")
or "."
local packCache = FileIO.makeCache(outRoot)
local rom = assert(Rom.open(imports, "firered"))
print("Extracting Pokémon pack →", outRoot .. "/data/generated/gba/pokemon")
local detail = PokemonExtract.run(rom, packCache, {
cacheRoot = "data/generated/gba",
progress = function(stage, cur, total)
if cur % 50 == 0 or cur + 1 >= total then
print(string.format(" [%s] %d/%d", stage, cur, total))
end
end,
})
rom:clearCache()
imports:_close()
print("OK pokemon", detail.numSpecies, "species →", detail.root)
if detail.partyChrome then
print("OK party chrome →", detail.partyChrome.root)
end
if detail.battleChrome then
print("OK battle chrome →", detail.battleChrome.root,
detail.battleChrome.textboxW, "x", detail.battleChrome.textboxH)
end
if detail.stats and detail.stats[1] then
local s = detail.stats[1]
print(string.format(" Bulbasaur stats hp=%d atk=%d def=%d spe=%d spa=%d spd=%d",
s.hp, s.atk, s.def, s.spe, s.spa, s.spd))
end
if detail.abilityNames and detail.abilityNames[1] then
print(" ability 1 =", detail.abilityNames[1])
end
if detail.learnsets and detail.learnsets[1] then
local parts = {}
for _, e in ipairs(detail.learnsets[1]) do
local name = detail.moveNames and detail.moveNames[e.move] or tostring(e.move)
parts[#parts + 1] = string.format("Lv%d %s", e.level, name)
end
print(" Bulbasaur learnset:", table.concat(parts, ", "))
end
if detail.battleMoves and detail.battleMoves.moves and detail.battleMoves.moves[33] then
local t = detail.battleMoves.moves[33]
print(string.format(" TACKLE pp=%d power=%d", t.pp, t.power))
end
os.exit(0)
end
if itemsOnly then
local Rom = require("src.import.gba.rom")
local ItemsExtract = require("src.import.gba.items_extract")
local outRoot = os.getenv("HOME")
and (os.getenv("HOME") .. "/.local/share/love/pokemon-love2d/firered")
or "."
local packCache = FileIO.makeCache(outRoot)
local rom = assert(Rom.open(imports, "firered"))
print("Extracting items pack →", outRoot .. "/data/generated/gba/items")
local detail = ItemsExtract.run(rom, packCache, { cacheRoot = "data/generated/gba" })
rom:clearCache()
imports:_close()
print("OK items", detail and detail.count or "?", "→", detail and detail.path or outRoot)
os.exit(0)
end
if pokedexOnly then
local Rom = require("src.import.gba.rom")
local PokedexExtract = require("src.import.gba.pokedex_chrome_extract")
local outRoot = os.getenv("HOME")
and (os.getenv("HOME") .. "/.local/share/love/pokemon-love2d/firered")
or "."
local packCache = FileIO.makeCache(outRoot)
local rom = assert(Rom.open(imports, "firered"))
print("Extracting Pokédex data from ROM →", outRoot .. "/data/generated/gba/pokemon/pokedex")
local ok = PokedexExtract.run(rom, packCache, {
cacheRoot = "data/generated/gba",
force = true,
progress = function(stage, cur, total)
print(string.format(" [%s] %d/%d", stage, cur, total))
end,
})
rom:clearCache()
imports:_close()
print("OK pokedex entries, categories, and orders extracted from ROM")
os.exit(0)
end
if martsOnly then
local Rom = require("src.import.gba.rom")
local MartsExtract = require("src.import.gba.marts_extract")
local outRoot = outDir
local packCache = cache
local rom = assert(Rom.open(imports, "firered"))
print("Extracting pokemart lists →", outRoot .. "/data/generated/gba/scripts/marts.lua")
local detail, err = MartsExtract.run(rom, packCache, { cacheRoot = Extract.CACHE_ROOT })
rom:clearCache()
imports:_close()
if not detail then
io.stderr:write("FAIL marts: " .. tostring(err) .. "\n")
os.exit(1)
end
print("OK marts", detail.listCount, "lists from", detail.ptrCount, "ptrs →", detail.path)
os.exit(0)
end
if scriptsOnly then
local Rom = require("src.import.gba.rom")
local ExtractScripts = require("src.import.gba.extract_scripts")
local Versions = require("src.import.gba.versions")
local outRoot = outDir
local packCache = cache
local rom = assert(Rom.open(imports, "firered"))
local version = Versions.lookup(rom.md5)
print("Extracting scripts bundle →", outRoot .. "/data/generated/gba/scripts")
local bundle = ExtractScripts.writeBundleFromRom(rom, packCache, Extract.CACHE_ROOT, version)
rom:clearCache()
imports:_close()
print("OK scripts", bundle.scriptCount, "scripts →", Extract.CACHE_ROOT .. "/scripts")
os.exit(0)
end
if bagChromeOnly then
local Rom = require("src.import.gba.rom")
local BagChromeExtract = require("src.import.gba.bag_chrome_extract")
local rom = assert(Rom.open(imports, "firered"))
print("Extracting bag chrome + icons →", outDir .. "/data/generated/gba/items/bag")
local detail = BagChromeExtract.run(rom, cache, {
cacheRoot = Extract.CACHE_ROOT,
progress = function(stage, cur, total)
if cur % 50 == 0 or cur + 1 >= total then
print(string.format(" [%s] %d/%d", stage, cur, total))
end
end,
})
rom:clearCache()
imports:_close()
print("OK bag-chrome icons=", detail.iconsBaked, "→", detail.root)
os.exit(0)
end
if storageChromeOnly then
local Rom = require("src.import.gba.rom")
local StorageChromeExtract = require("src.import.gba.storage_chrome_extract")
local rom = assert(Rom.open(imports, "firered"))
print("Extracting storage chrome + wallpapers →", outDir .. "/data/generated/gba/pokemon/storage")
local detail = StorageChromeExtract.run(rom, cache, {
cacheRoot = Extract.CACHE_ROOT,
force = true,
})
rom:clearCache()
imports:_close()
print("OK storage-chrome assets=", detail.count or "?", "→", detail.root or (Extract.CACHE_ROOT .. "/pokemon/storage"))
os.exit(0)
end
if mapTreeOnly then
local Rom = require("src.import.gba.rom")
local Versions = require("src.import.gba.versions")
local MapTreeExtract = require("src.import.gba.map_tree_extract")
local outRoot = os.getenv("HOME")
and (os.getenv("HOME") .. "/.local/share/love/pokemon-love2d/firered")
or "."
local packCache = FileIO.makeCache(outRoot)
local version = assert(Versions.lookup(md5))
local rom = assert(Rom.open(imports, "firered"))
print("Walking gMapGroups →", outRoot .. "/data/generated/gba/map_tree")
local ok, detail = MapTreeExtract.run(rom, packCache, {
version = version,
progress = function(name, cur, total)
if cur % 50 == 0 or cur + 1 >= total then
print(string.format(" %s %d/%d", name, cur, total))
end
end,
})
rom:clearCache()
imports:_close()
if not ok then
io.stderr:write("FAIL map-tree: " .. tostring(detail) .. "\n")
os.exit(1)
end
print("OK map-tree", detail.map_count, "maps", detail.tileset_count, "tilesets")
print(" →", outRoot .. "/" .. detail.root)
os.exit(0)
end
local runner = nativeOnly and Extract.runNativeOnly or Extract.run
local ok, detail = runner(imports, cache, function(stage, n, name, cur, total)
print(string.format(" [%d/%d] %s %s/%s", stage, n, name, tostring(cur), tostring(total)))
end)
imports:_close()
if not ok then
io.stderr:write("FAIL: " .. tostring(detail) .. "\n")
os.exit(1)
end
if detail.native_only then
print("OK native-only", detail.md5,
"pairs=" .. tostring(detail.pairs), "maps=" .. tostring(detail.maps))
else
print("OK", detail.tile_count, "tiles", detail.block_count, "blocks", detail.mid_count, "mids")
end
print("Wrote CacheFS", outDir .. "/" .. Extract.CACHE_ROOT)