Files
gen1recomp/src/import/RomExtractorGen3.lua
T
Shane McGovern e2454bf568 fix(game3): review v3 fixes (battle, field, script, save) + gated T6 tier
Second wave of fixes from the Gen 3 codebase review (210 findings), the
regressions the full-suite sweep surfaced, and a T6 tier so the top-level
tests/game3_*.lua suites run inside ./scripts/test.sh instead of outside it.

Battle: Knock Off and Thief/Trick item persistence follow the ROM, not the
review - the party slot is never written through during battle, and the
knocked-off mask suppresses the item on later send-outs
(pokefirered/src/battle_script_commands.c:2731 MOVE_EFFECT_KNOCK_OFF with the
STICKY_HOLD guard at :2732, opponent-steal guard at :2610-2622, mask use at
:4489). Rapid Spin keeps its one-per-use chain order (:8435-8474); Growl
keeps pret's target (src/data/battle_moves.h, include/battle.h:63).

Scripting: operand layouts match pret/asm/macros/event.inc - givemon 15 bytes
(:989-997), comparestat {B,W} (:1573-1576), setptr/loadbytefromptr/setptrbyte
take a word pointer (:118-137). 18 of the 20 E10 ops are wired per
src/scrcmd.c (per-op citations in docs/game3/e10-opcode-spec.md), money and
random and the warp family read their operands as VarGets
(src/scrcmd.c:1798-1830, :455-461, :719-731), and the day-care party-full
guard is in place (src/daycare.c:525,:1081).

Field and UI: fishing counts rounds as the ROM does
(src/field_player_avatar.c:1740-1765), an unresolved map section no longer
reports Pallet Town (src/region_map.c:3782), and a font provider sits behind
a FireRed capability/profile seam.

Save data: profile-driven Options.block, a save version round-trip, and slot
id validation that blocks path traversal (slotDiskPath("firered", "../evil")
returns nil).

Importer: the parallel import path now writes both completion markers,
object kind/clone bytes decode as pret defines them
(include/global.fieldmap.h:110-130), and the HM table drops Whirlpool
(include/constants/items.h:411-418).

Tests: six new scenario suites (battle_ai, capture, event, menu, move,
overworld) and eight engine suites (profile, capabilities, cache paths, font
provider, options block, save version round-trip, version dispatch,
versions_game). T6 runs every top-level game3 suite at GAME3_JOBS default 8
with per-suite logs, failure cause classification and a KNOWN_GAME3_FAILURES
ceiling of 23 as a shrinking guard.

Docs: the working notes under docs/game3 (triage ledger, pret citation audit,
e10 opcode spec, RSE seams, sweep v3/v113, test baseline, artifact
conversions, merge trial).

Full gate at this snapshot: exit 0, all tiers passed - engine 624/624,
T6 279/279 (0 known, 0 fresh failures), gen2 146/146, modkit 37/37, luacheck
clean, privacy gate 3/3 over a 9,711-file publication set.
2026-09-22 12:22:50 +01:00

678 lines
25 KiB
Lua

-- FireRed Gen 3 extractor: GBA ROM → firered/ cache under CacheFs.prefix.
-- Parallel to RomExtractor / RomExtractorGen2. Full Island-1 demake lives in
-- src/import/gba/extract_island1.lua; this module publishes the CacheContract
-- override files and, when possible, runs that extract under
-- data/generated/gba/.
local CacheFs = require("src.import.CacheFs")
local LuaWriter = require("src.import.LuaWriter")
local RomExtractorGen3 = {}
RomExtractorGen3.__index = RomExtractorGen3
local STAGE_COUNT = 5
local GBA_ROOT = "data/generated/gba"
local function hexSha1(data)
local digest = love.data.hash("sha1", data)
if type(digest) == "userdata" and digest.getString then
digest = digest:getString()
end
return love.data.encode("string", "hex", digest)
end
local function writeText(rel, body)
local ok, err = CacheFs.write(rel, body)
if not ok then
error("could not write " .. rel .. ": " .. tostring(err))
end
end
local function writeJson(rel, obj)
local Json = require("src.link.Json")
writeText(rel, Json.encode(obj) .. "\n")
end
local function makeImports(romData, sha1)
return {
info = function(_, id)
if id ~= "firered" and id ~= "leafgreen" then
return nil, "undeclared"
end
return {
id = "firered",
size = #romData,
-- versions.lua looks up by SHA-1 (legacy field name is md5).
md5 = sha1,
file = "memory",
}
end,
read = function(_, id, offset, length)
if id ~= "firered" and id ~= "leafgreen" then
return nil, "undeclared"
end
if offset < 0 or length < 0 or offset + length > #romData then
return nil, "short read"
end
return romData:sub(offset + 1, offset + length)
end,
}
end
local function makeCache()
return {
write = function(_, rel, bytes)
return CacheFs.write(rel, bytes)
end,
read = function(_, rel)
return CacheFs.read(rel)
end,
exists = function(_, rel)
return CacheFs.exists(rel)
end,
info = function(_, rel)
if CacheFs.exists(rel) then return { type = "file" } end
return nil
end,
}
end
local POKEMON_SUBTASKS = {
["pokemon"] = { min = 0.00, max = 0.40, label = "Pokémon Species & Sprites" },
["learnsets"] = { min = 0.40, max = 0.55, label = "Move Learnsets" },
["battle_moves"] = { min = 0.55, max = 0.60, label = "Battle Moves Data" },
["party_chrome"] = { min = 0.60, max = 0.65, label = "Party UI Graphics" },
["battle_chrome"] = { min = 0.65, max = 0.70, label = "Battle UI Graphics" },
["pokedex_entries"] = { min = 0.70, max = 0.73, label = "Pokédex Database" },
["pokedex_categories"]= { min = 0.73, max = 0.75, label = "Pokédex Categories" },
["pokedex_orders"] = { min = 0.75, max = 0.77, label = "Pokédex Sorting" },
["pokedex_done"] = { min = 0.77, max = 0.78, label = "Pokédex Complete" },
["storage_chrome"] = { min = 0.78, max = 0.82, label = "PC Storage Chrome" },
["battle_transition"] = { min = 0.82, max = 0.86, label = "Battle Transitions" },
["summary_chrome"] = { min = 0.86, max = 0.90, label = "Summary Screen Graphics" },
["bag_chrome"] = { min = 0.90, max = 0.94, label = "Bag & Items Graphics" },
["shop_chrome"] = { min = 0.94, max = 0.97, label = "Mart & Shop Graphics" },
["trainers"] = { min = 0.97, max = 0.99, label = "Trainer Data & Parties" },
["map_preview"] = { min = 0.99, max = 1.00, label = "Location Previews" },
}
function RomExtractorGen3.new(romData, manifest, progressCb, romSha1)
return setmetatable({
romData = romData,
manifest = manifest,
progress = progressCb,
romSha1 = romSha1,
stage = 0,
_lastPct = 0,
}, RomExtractorGen3)
end
function RomExtractorGen3:sharedImports(sha1)
if not self._imports then
self._imports = makeImports(self.romData, sha1)
end
return self._imports
end
function RomExtractorGen3:ensureSha1()
if type(self.romSha1) == "string" and self.romSha1 ~= "" then
return self.romSha1
end
self.romSha1 = hexSha1(self.romData)
return self.romSha1
end
function RomExtractorGen3:report(pct, stageName, current, stageTotal)
pct = math.max(self._lastPct or 0, math.min(1.0, pct or 0))
self._lastPct = pct
if self.progress then
self.progress(math.floor(pct * 1000), 1000, stageName or "Extracting", current or 0, stageTotal or 1)
end
end
function RomExtractorGen3:beginStage(name)
self.stage = self.stage + 1
self:report((self.stage - 1) / STAGE_COUNT, name, 0, 1)
end
function RomExtractorGen3:tickPokemon(name, current, total)
local st = POKEMON_SUBTASKS[name]
if st then
local curFrac = (current or 0) / math.max(total or 1, 1)
local frac = st.min + curFrac * (st.max - st.min)
local label = st.label
if total and total > 1 then
label = label .. string.format(" (%d/%d)", current or 0, total)
end
self:report(frac, label, current, total)
else
local frac = (current or 0) / math.max(total or 1, 1)
self:report(frac, name or "Pokémon Data", current, total)
end
end
-- Minimal trees CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.firered needs,
-- plus semantic module stubs for SEMANTIC_MODULES[3].
function RomExtractorGen3:writeRequiredMarkers(sha1)
local Versions = require("src.import.gba.versions")
local cache = makeCache()
local metaRaw = cache:read(GBA_ROOT .. "/meta.json")
if not metaRaw or not metaRaw:find('"md5"%s*:%s*"' .. sha1 .. '"') or not metaRaw:find('"cache_version"%s*:%s*' .. tostring(Versions.CACHE_VERSION)) then
writeJson(GBA_ROOT .. "/meta.json", {
romSha1 = sha1,
md5 = sha1,
version = "firered",
cache_version = Versions.CACHE_VERSION,
native_version = Versions.NATIVE_VERSION or 5,
stub = false,
})
end
if not CacheFs.exists(GBA_ROOT .. "/maps.json") then
writeJson(GBA_ROOT .. "/maps.json", {
maps = {},
stub = true,
})
end
if not CacheFs.exists(GBA_ROOT .. "/intro/meta.json") then
writeJson(GBA_ROOT .. "/intro/meta.json", {
stub = true,
})
end
if not CacheFs.exists(GBA_ROOT .. "/audio/meta.json") then
writeJson(GBA_ROOT .. "/audio/meta.json", {
stub = true,
})
end
if not CacheFs.exists("data/generated/maps.lua") then
LuaWriter.write("data/generated/maps.lua", { stub = true, maps = {} })
end
if not CacheFs.exists("data/generated/intro.lua") then
LuaWriter.write("data/generated/intro.lua", {
stub = true,
generation = 3,
version = "firered",
})
end
if not CacheFs.exists("data/generated/audio.lua") then
LuaWriter.write("data/generated/audio.lua", { stub = true })
end
end
function RomExtractorGen3:runGbaExtract(sha1)
local Extract = require("src.import.gba.extract_island1")
local prevRoot, prevNative = Extract.CACHE_ROOT, Extract.NATIVE_ROOT
Extract.CACHE_ROOT = GBA_ROOT
Extract.NATIVE_ROOT = GBA_ROOT .. "/native"
local imports = self:sharedImports(sha1)
local cache = makeCache()
local runOk, runDetail = false, "extract did not run"
local callOk, err = pcall(function()
runOk, runDetail = Extract.run(imports, cache, function(stage, n, name, cur, total)
local curFrac = (cur or 0) / math.max(total or 1, 1)
local frac = ((stage or 0) + curFrac) / math.max(n or Extract.STAGE_COUNT or 7, 1)
local label = "World Maps: " .. tostring(name or "Processing")
if total and total > 1 then
label = label .. string.format(" (%d/%d)", cur or 0, total)
end
self:report(math.min(frac, 1.0), label, cur or 0, total or 1)
end)
end)
Extract.CACHE_ROOT = prevRoot
Extract.NATIVE_ROOT = prevNative
if not callOk then
return false, err
end
return runOk, runDetail
end
--- Species pack + party chrome into data/generated/gba/pokemon/.
function RomExtractorGen3:runPokemonExtract(sha1)
local Rom = require("src.import.gba.rom")
local PokemonExtract = require("src.import.gba.pokemon_extract")
local Extract = require("src.import.gba.extract_island1")
local prevRoot = Extract.CACHE_ROOT
Extract.CACHE_ROOT = GBA_ROOT
local cache = makeCache()
if PokemonExtract.ready(cache, GBA_ROOT) then
Extract.CACHE_ROOT = prevRoot
return true, { skipped = true }
end
local imports = self:sharedImports(sha1)
local rom, openErr = Rom.open(imports, "firered")
if not rom then
Extract.CACHE_ROOT = prevRoot
return false, openErr or "rom open failed"
end
local ok, detail = pcall(function()
local pRes = PokemonExtract.run(rom, cache, {
cacheRoot = GBA_ROOT,
progress = function(name, cur, total)
self:tickPokemon(name or "pokemon", cur or 0, total or 1)
end,
})
local ItemsExtract = require("src.import.gba.items_extract")
ItemsExtract.run(rom, cache, { cacheRoot = GBA_ROOT })
local PokedexExtract = require("src.import.gba.pokedex_chrome_extract")
PokedexExtract.run(rom, cache, { cacheRoot = GBA_ROOT })
local StorageExtract = require("src.import.gba.storage_chrome_extract")
StorageExtract.run(rom, cache, { cacheRoot = GBA_ROOT })
local TextChromeExtract = require("src.import.gba.text_chrome_extract")
TextChromeExtract.run(rom, cache, { cacheRoot = GBA_ROOT })
local TrainerCardExtract = require("src.import.gba.trainer_card_extract")
TrainerCardExtract.run(rom, cache, { cacheRoot = GBA_ROOT })
local SeagallopExtract = require("src.import.gba.seagallop_extract")
SeagallopExtract.run(rom, cache, { cacheRoot = GBA_ROOT })
local MapPreviewExtract = require("src.import.gba.map_preview_extract")
local mpOk, mpErr = pcall(MapPreviewExtract.run, rom, cache, {
cacheRoot = GBA_ROOT,
progress = function(name, cur, total)
self:tickPokemon(name or "map_preview", cur or 0, total or 1)
end,
})
if not mpOk then
print("[map_preview] warn: " .. tostring(mpErr))
end
return pRes
end)
Extract.CACHE_ROOT = prevRoot
if not ok then
return false, detail
end
return true, detail
end
function RomExtractorGen3:runAuxExtracts(sha1)
local Rom = require("src.import.gba.rom")
local RegionMapExtract = require("src.import.gba.region_map_extract")
local MapSectionsExtract = require("src.import.gba.map_sections_extract")
local MultichoiceExtract = require("src.import.gba.multichoice_extract")
local HealLocationsExtract = require("src.import.gba.heal_locations_extract")
local DoorAnimExtract = require("src.import.gba.door_anim_extract")
local SlotMachineExtract = require("src.import.gba.slot_machine_extract")
local TradeExtract = require("src.import.gba.trade_extract")
local LinkArtExtract = require("src.import.gba.link_art_extract")
local FameCheckerExtract = require("src.import.gba.fame_checker_extract")
local TeachyTvExtract = require("src.import.gba.teachy_tv_extract")
local MysteryGiftExtract = require("src.import.gba.mystery_gift_extract")
local TrainerTowerExtract = require("src.import.gba.trainer_tower_extract")
local TutorExtract = require("src.import.gba.tutor_extract")
local MuseumExtract = require("src.import.gba.museum_extract")
local MoveRelearnerExtract = require("src.import.gba.move_relearner_extract")
local EggExtract = require("src.import.gba.egg_extract")
local BattleAnimExtract = require("src.import.gba.battle_anim_extract")
local BattleAiExtract = require("src.import.gba.battle_ai_extract")
local Extract = require("src.import.gba.extract_island1")
local prevRoot = Extract.CACHE_ROOT
Extract.CACHE_ROOT = GBA_ROOT
local cache = makeCache()
local needRegion = not RegionMapExtract.ready(cache, GBA_ROOT)
local needSections = not CacheFs.exists(GBA_ROOT .. "/region_map/map_sections.lua")
local needChoices = not MultichoiceExtract.ready(cache, GBA_ROOT)
local needHeal = not HealLocationsExtract.ready(cache, GBA_ROOT)
local needDoors = not DoorAnimExtract.ready(cache, GBA_ROOT)
local needSlots = not SlotMachineExtract.ready(cache, GBA_ROOT)
local needTrade = not TradeExtract.ready(cache, GBA_ROOT)
local needLinkArt = not LinkArtExtract.ready(cache, GBA_ROOT)
local needFame = not FameCheckerExtract.ready(cache, GBA_ROOT)
local needTeachy = not TeachyTvExtract.ready(cache, GBA_ROOT)
local needGift = not MysteryGiftExtract.ready(cache, GBA_ROOT)
local needTower = not TrainerTowerExtract.ready(cache, GBA_ROOT)
local needTutor = not TutorExtract.ready(cache, GBA_ROOT)
local needMuseum = not MuseumExtract.ready(cache, GBA_ROOT)
local needRelearner = not MoveRelearnerExtract.ready(cache, GBA_ROOT)
local needEgg = not EggExtract.ready(cache, GBA_ROOT)
local needAnims = not BattleAnimExtract.ready(cache, GBA_ROOT)
local needAi = not BattleAiExtract.ready(cache, GBA_ROOT)
if not (needRegion or needSections or needChoices or needHeal or needDoors
or needSlots or needTrade or needLinkArt or needFame or needTeachy
or needGift or needTower or needTutor or needMuseum or needRelearner
or needEgg or needAnims or needAi) then
Extract.CACHE_ROOT = prevRoot
return true, { skipped = true }
end
local rom, openErr = Rom.open(self:sharedImports(sha1), "firered")
if not rom then
Extract.CACHE_ROOT = prevRoot
return false, openErr or "rom open failed"
end
local ok, detail = pcall(function()
local out = {}
local step = 0
local totalSteps = 17
local function auxTick(name)
step = step + 1
self:report(step / totalSteps, "Game Data: " .. name, step, totalSteps)
end
if needRegion then
out.regionMap = RegionMapExtract.run(rom, cache, { cacheRoot = GBA_ROOT })
end
auxTick("Town Map")
if needSections then
MapSectionsExtract.run(rom, cache, { cacheRoot = GBA_ROOT })
local rel = GBA_ROOT .. "/region_map/map_sections.lua"
local body = cache:read(rel)
if type(body) ~= "string" or #body < 1024 then
error("map_sections extract wrote " .. tostring(body and #body or 0)
.. " bytes to " .. rel)
end
out.mapSections = #body
end
auxTick("Map Sections")
if needChoices then
out.multichoice = MultichoiceExtract.run(rom, cache, { cacheRoot = GBA_ROOT })
end
auxTick("Dialog Menus")
if needHeal then
local okHl, detailHl = HealLocationsExtract.run(rom, cache, { cacheRoot = GBA_ROOT })
if not okHl then print("[heal_locations] warn: " .. tostring(detailHl)) end
out.healLocations = detailHl
end
auxTick("Heal Locations")
if needDoors then
local okDr, detailDr = pcall(DoorAnimExtract.run, rom, cache, { cacheRoot = GBA_ROOT })
if not okDr then print("[door_extract] warn: " .. tostring(detailDr)) end
out.doors = okDr and detailDr or false
end
auxTick("Door Animations")
if needSlots then
local okSl, detailSl = pcall(SlotMachineExtract.run, rom, cache, { cacheRoot = GBA_ROOT })
if not okSl then print("[slot_machine_extract] warn: " .. tostring(detailSl)) end
out.slotMachine = okSl and detailSl or false
end
auxTick("Slot Machines")
if needTrade then
local okTr, detailTr = pcall(TradeExtract.run, rom, cache, { cacheRoot = GBA_ROOT })
if not okTr then print("[trade_extract] warn: " .. tostring(detailTr)) end
out.trade = okTr and detailTr or false
end
auxTick("Link Trade")
if needLinkArt then
local okLk, detailLk = pcall(LinkArtExtract.run, rom, cache, { cacheRoot = GBA_ROOT })
if not okLk then print("[link_art_extract] warn: " .. tostring(detailLk)) end
out.linkArt = okLk and detailLk or false
end
auxTick("Wireless Union")
if needFame then
local okFc, detailFc = pcall(FameCheckerExtract.run, rom, cache, { cacheRoot = GBA_ROOT })
if not okFc then print("[fame_checker_extract] warn: " .. tostring(detailFc)) end
out.fameChecker = okFc and detailFc or false
end
auxTick("Fame Checker")
if needTeachy then
local okTv, detailTv = pcall(TeachyTvExtract.run, rom, cache, { cacheRoot = GBA_ROOT })
if not okTv then print("[teachy_tv_extract] warn: " .. tostring(detailTv)) end
out.teachyTv = okTv and detailTv or false
end
auxTick("Teachy TV")
if needGift then
local okMg, detailMg = pcall(MysteryGiftExtract.run, rom, cache, { cacheRoot = GBA_ROOT })
if not okMg then print("[mystery_gift_extract] warn: " .. tostring(detailMg)) end
out.mysteryGift = okMg and detailMg or false
end
auxTick("Mystery Gift")
if needTower then
local okTt, detailTt = pcall(TrainerTowerExtract.run, rom, cache, { cacheRoot = GBA_ROOT })
if not okTt then print("[trainer_tower_extract] warn: " .. tostring(detailTt)) end
out.trainerTower = okTt and detailTt or false
end
auxTick("Trainer Tower")
if needTutor then
local okTu, detailTu = pcall(TutorExtract.run, rom, cache, { cacheRoot = GBA_ROOT })
if not okTu then print("[tutor_extract] warn: " .. tostring(detailTu)) end
out.tutor = okTu and detailTu or false
end
auxTick("Move Tutors")
if needMuseum then
local okMu, detailMu = pcall(MuseumExtract.run, rom, cache, { cacheRoot = GBA_ROOT })
if not okMu then print("[museum_extract] warn: " .. tostring(detailMu)) end
out.museum = okMu and detailMu or false
end
auxTick("Museum Exhibits")
if needRelearner then
local okMr, detailMr = pcall(MoveRelearnerExtract.run, rom, cache, { cacheRoot = GBA_ROOT })
if not okMr then print("[move_relearner_extract] warn: " .. tostring(detailMr)) end
out.moveRelearner = okMr and detailMr or false
end
auxTick("Move Relearner")
if needEgg then
local okEg, detailEg = pcall(EggExtract.run, rom, cache, { cacheRoot = GBA_ROOT })
if not okEg then print("[egg_extract] warn: " .. tostring(detailEg)) end
out.egg = okEg and detailEg or false
end
auxTick("Egg & Hatching")
if needAnims then
local okAn, detailAn = pcall(BattleAnimExtract.run, rom, cache, { cacheRoot = GBA_ROOT, force = false })
if not okAn then print("[battle_anim_extract] warn: " .. tostring(detailAn)) end
out.battleAnims = okAn and detailAn or false
end
auxTick("Battle Animations")
if needAi then
local okAi, detailAi = pcall(BattleAiExtract.run, {
cache = cache,
cacheRoot = GBA_ROOT,
pretRoot = os.getenv("POKEFIRERED"),
})
if not okAi then print("[battle_ai_extract] warn: " .. tostring(detailAi)) end
out.battleAi = okAi and detailAi or false
end
auxTick("Battle AI Scripts")
return out
end)
rom:clearCache()
Extract.CACHE_ROOT = prevRoot
if not ok then return false, detail end
return true, detail
end
function RomExtractorGen3:runIntroAudio(sha1)
self:report(0.05, "Audio & Intro: Initializing", 0, 3)
local cache = makeCache()
if cache:exists(GBA_ROOT .. "/intro/meta.json") and cache:exists(GBA_ROOT .. "/audio/meta.json") then
local im = cache:read(GBA_ROOT .. "/intro/meta.json")
if im and not im:find('"stub"%s*:%s*true') then
self:report(1.00, "Audio Streams Ready", 3, 3)
return true, true
end
end
local RevisionView = require("src.import.gba.revision_view")
local imports = self:sharedImports(sha1)
local info = imports:info("firered")
local romShim = { data = RevisionView.forImports(imports, "firered", info) or self.romData }
local Intro = require("src.import.gba.extract_intro")
local Naming = require("src.import.gba.extract_naming")
local AudioExt = require("src.import.gba.extract_audio")
self:report(0.20, "Intro: Cutscene Sequence", 1, 3)
local okI, metaI = Intro.run(romShim, cache, { sha1 = sha1, root = GBA_ROOT .. "/intro" })
self:report(0.50, "Intro: Naming Screen Graphics", 2, 3)
local okN = Naming.run(romShim, cache, { sha1 = sha1, root = GBA_ROOT .. "/naming" })
self:report(0.75, "Audio: Music & Sound Streams", 3, 3)
local okA, metaA = AudioExt.run(romShim, cache, { sha1 = sha1, root = GBA_ROOT .. "/audio" })
writeJson(GBA_ROOT .. "/intro/extract_status.json", { ok = okI == true })
writeJson(GBA_ROOT .. "/naming/extract_status.json", { ok = okN == true })
writeJson(GBA_ROOT .. "/audio/extract_status.json", { ok = okA == true })
self:report(1.00, "Audio & Intro Ready", 3, 3)
return okI, okA, metaI, metaA
end
function RomExtractorGen3:runParallel(sha1)
if os.getenv("POKEPORT_NO_THREAD") == "1" then
return false, "POKEPORT_NO_THREAD set"
end
if not (love and love.thread and love.thread.newThread and love.timer) then
return false, "love.thread unavailable"
end
local ch_name = "gba_extract_" .. tostring(love.timer.getTime()):gsub("%.", "") .. "_" .. tostring(math.random(10000, 99999))
local ch = love.thread.getChannel(ch_name)
local tasks = { "gba", "pokemon", "aux", "intro_audio" }
local threads = {}
local prefix = CacheFs.prefix or ""
for _, t in ipairs(tasks) do
local okTh, th = pcall(love.thread.newThread, "src/import/gba/extract_worker.lua")
if not okTh or not th then
return false, "failed to spawn thread for " .. t .. ": " .. tostring(th)
end
threads[t] = th
th:start(t, prefix, self.romData, sha1, ch_name)
end
local done_count = 0
local errors = {}
local task_progress = { gba = 0, pokemon = 0, aux = 0, intro_audio = 0 }
local weights = { gba = 0.40, pokemon = 0.35, aux = 0.15, intro_audio = 0.10 }
while done_count < #tasks do
local msg = ch:pop()
if msg then
if msg.type == "progress" then
task_progress[msg.task] = math.max(task_progress[msg.task] or 0, math.min(1.0, msg.fraction or 0))
local total_pct = 0.03
for k, w in pairs(weights) do
total_pct = total_pct + (task_progress[k] or 0) * w * 0.95
end
self:report(total_pct, msg.stage or "Extracting", msg.current or 0, msg.stageTotal or 1)
elseif msg.type == "done" then
done_count = done_count + 1
task_progress[msg.task] = 1.0
local total_pct = 0.03
for k, w in pairs(weights) do
total_pct = total_pct + (task_progress[k] or 0) * w * 0.95
end
self:report(total_pct, "Finalizing " .. tostring(msg.task), done_count, #tasks)
if not msg.ok then
errors[#errors + 1] = msg.task .. ": " .. tostring(msg.error)
end
end
else
love.timer.sleep(0.005)
end
end
if #errors > 0 then
error("Parallel extraction error:\n" .. table.concat(errors, "\n"))
end
if not CacheFs.exists(GBA_ROOT .. "/maps.json") then
writeJson(GBA_ROOT .. "/maps.json", { maps = {}, from_extract = true })
end
return true
end
function RomExtractorGen3:run()
local sha1 = self:ensureSha1()
self:report(0.01, "Initializing Markers", 0, 1)
self:writeRequiredMarkers(sha1)
self:report(0.03, "Markers Ready", 1, 1)
local okPar, parErr = self:runParallel(sha1)
if okPar then
-- The parallel workers run the same stages as the sequential path, so a
-- fresh import must leave the same status markers behind. The region_map
-- marker gates tests/game3_region_map_assets_test.lua, which skipped on
-- fresh imports because only the fallback path wrote it.
writeJson(GBA_ROOT .. "/pokemon/extract_status.json", { ok = true, error = nil })
writeJson(GBA_ROOT .. "/region_map/extract_status.json", { ok = true, error = nil })
self:report(1.00, "Ready", 1, 1)
collectgarbage("collect")
return {
romSha1 = sha1,
extractOk = true,
pokemonOk = true,
auxOk = true,
}
end
-- Fallback sequential execution path
local ok, detail = self:runGbaExtract(sha1)
if ok then
if not CacheFs.exists(GBA_ROOT .. "/maps.json") then
writeJson(GBA_ROOT .. "/maps.json", { maps = {}, from_extract = true })
end
else
writeJson(GBA_ROOT .. "/extract_status.json", {
ok = false,
error = tostring(detail),
romSha1 = sha1,
})
error("GBA extract failed: " .. tostring(detail))
end
self:report(0.42, "World Maps Ready", 1, 1)
collectgarbage("collect")
local okPoke, pokeDetail = self:runPokemonExtract(sha1)
writeJson(GBA_ROOT .. "/pokemon/extract_status.json", {
ok = okPoke == true,
error = (not okPoke) and tostring(pokeDetail) or nil,
})
if not okPoke then
error("Pokemon extract failed: " .. tostring(pokeDetail))
end
self:report(0.88, "Game Data & Chrome Ready", 1, 1)
collectgarbage("collect")
local okAux, auxDetail = self:runAuxExtracts(sha1)
writeJson(GBA_ROOT .. "/region_map/extract_status.json", {
ok = okAux == true,
error = (not okAux) and tostring(auxDetail) or nil,
})
if not okAux then
error("Region map / script table extract failed: " .. tostring(auxDetail))
end
collectgarbage("collect")
self:runIntroAudio(sha1)
self:report(0.98, "Finalizing Cache", 1, 1)
collectgarbage("collect")
self:report(1.00, "Ready", 1, 1)
return {
romSha1 = sha1,
extractOk = ok == true,
pokemonOk = okPoke == true,
auxOk = okAux == true,
detail = detail,
}
end
return RomExtractorGen3