-- Gen 1 module facades for a FireRed boot: the Gen 1 API backed by src/core/game3. -- Contract mirrors src/mods/Gen2Compat.lua; docs/mod-api-gen3-compat.md. local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local GameVersion = require("src.core.GameVersion") local Gen3Compat = {} local rawRequire = require local resolveGame = nil local lastGame = nil local built = {} local claimants = {} local warned = {} local function warnOnce(key, fmt, ...) if warned[key] then return end warned[key] = true Logger.warn(fmt, ...) end local function who(name) local ids = claimants[name] if not ids or #ids == 0 then return "a gen3 mod" end return table.concat(ids, ", ") end local function live() local g = resolveGame and resolveGame() or nil return g or lastGame end local function unbacked(module, member, why) return function() warnOnce(module .. "." .. member, "[%s] %s.%s has no Gen 3 backing: %s", who(module), module, member, why) return nil end end local function g3(name) local ok, module = pcall(rawRequire, "src.core.game3." .. name) if ok then return module end return nil end local function ui3(name) local ok, module = pcall(rawRequire, "src.ui.game3." .. name) if ok then return module end return nil end local function session() local R = package.loaded["src.core.game3.runtime"] local s = R and R.getSession and R.getSession() if s then return s end local g = live() return g and g.session or nil end local function space() return package.loaded["src.core.game3.scripting.space"] end local function flagStore() local S = space() return S and S.store or nil end local function inField() local g = live() if g and g.phase ~= nil and g.phase ~= "field" then return false end return session() ~= nil end local COVERAGE = {} Gen3Compat.COVERAGE_VERSION = 1 Gen3Compat.STATUS = { BACKED = "backed", WARNED = "warned", ABSENT = "absent" } local function words(s) local out = {} for w in tostring(s or ""):gmatch("%S+") do out[#out + 1] = w end return out end -- ------- ids local MAP_PREFIX = "FR_" Gen3Compat.FAMILIES = { frlg = true } function Gen3Compat.appliesTo(version) if type(version) ~= "string" then return true end if not GameVersion.VERSIONS[version] then return true end return Gen3Compat.FAMILIES[GameVersion.layout(version)] == true end function Gen3Compat.gen1MapId(id) if type(id) ~= "string" then return id end if id:sub(1, #MAP_PREFIX) == MAP_PREFIX then return id:sub(#MAP_PREFIX + 1) end return id end function Gen3Compat.gen3MapId(id) if type(id) ~= "string" then return id end local g = live() local maps = g and g.data and g.data.maps if maps then if maps[id] then return id end if maps[MAP_PREFIX .. id] then return MAP_PREFIX .. id end return nil end if id:sub(1, #MAP_PREFIX) == MAP_PREFIX then return id end return MAP_PREFIX .. id end local function canonicalName(display) if type(display) ~= "string" then return nil end local s = display:upper() s = s:gsub("♂", "_M"):gsub("♀", "_F") s = s:gsub("[%.']", ""):gsub("[%s%-]+", "_") s = s:gsub("[^%w_]", ""):gsub("_+", "_"):gsub("^_", ""):gsub("_$", "") return s end function Gen3Compat.speciesId(ref) if type(ref) == "number" then return ref end if type(ref) ~= "string" then return nil end local n = tonumber(ref) if n then return n end local P = g3("pokemon") return P and P.speciesFromName and P.speciesFromName(ref) or nil end function Gen3Compat.speciesName(sp) if type(sp) == "string" and not tonumber(sp) then return sp end sp = tonumber(sp) if not sp then return nil end local P = g3("pokemon") if P and P.keyName then local key = P.keyName(sp) if key then return key end end if not (P and P.name) then return nil end return canonicalName(P.name(sp)) end function Gen3Compat.itemId(ref) if type(ref) == "number" then return ref end local D = g3("items_data") local num = D and D.toNumericId and D.toNumericId(ref) if num then return num end if type(ref) == "string" and D and D.toNumericId then return D.toNumericId((ref:gsub("_", " "))) end return nil end function Gen3Compat.moveId(ref) if type(ref) == "number" then return ref end if type(ref) ~= "string" then return nil end local n = tonumber(ref) if n then return n end local M = g3("battle.moves") if not M then return nil end local norm = M.normalizeId and M.normalizeId(ref) or ref local num = M.numForName and M.numForName(norm) or nil if num then return num end local okS, Schemas = pcall(rawRequire, "src.mods.Schemas") if okS and Schemas and Schemas.gen3View then local n = Schemas.gen3View.moveNum(M, ref) if type(n) == "number" then return n end end return nil end local function schemaId(display) if type(display) ~= "string" or display == "" then return nil end local okS, Schemas = pcall(rawRequire, "src.mods.Schemas") if okS and Schemas and Schemas.gen3View then return Schemas.gen3View.idOf(display) end return canonicalName(display) end function Gen3Compat.moveName(ref) if type(ref) == "table" then ref = ref.numId or ref.id or ref.move end if type(ref) == "string" and not tonumber(ref) then return ref end local num = tonumber(ref) if not num or num < 1 then return nil end local P = g3("pokemon") local name = P and P.moveName and P.moveName(num) if type(name) ~= "string" or name == "" or name:match("^MOVE ") then local M = g3("battle.moves") name = M and M.BY_NUM and M.BY_NUM[num] or nil end return schemaId(name) or tostring(num) end function Gen3Compat.itemName(ref) if ref == nil or ref == 0 then return nil end if type(ref) == "string" and not tonumber(ref) then return ref end local num = tonumber(ref) if not num or num < 1 then return nil end local D = g3("items_data") local name = D and D.displayName and D.displayName(num) return schemaId(name) or tostring(num) end local function typeName(t) local okS, Schemas = pcall(rawRequire, "src.mods.Schemas") local types = okS and Schemas and Schemas.gen3View and Schemas.gen3View.TYPES local n = tonumber(t) return (types and n and types[n]) or t end function Gen3Compat.moveView(ref) local M = g3("battle.moves") if not M then return nil end local def = type(ref) == "table" and ref.effect ~= nil and ref or M.get(ref) if not def then return nil end local num = tonumber(def.numId) or Gen3Compat.moveId(type(ref) == "table" and def.id or ref) return { id = Gen3Compat.moveName(num) or def.id, index = num, num = num, name = M.displayName and M.displayName(num or def.id) or def.id, type = typeName(def.type), gen3Type = tonumber(def.type), power = def.power, accuracy = def.accuracy, pp = def.pp, priority = def.priority, category = def.category, effect = def.effect, target = def.target, secondaryChance = def.secondaryChance, } end function Gen3Compat.speciesView(sp) sp = Gen3Compat.speciesId(sp) if not sp then return nil end local P = g3("pokemon") local meta = P and P.speciesMeta and P.speciesMeta(sp) or {} return { id = Gen3Compat.speciesName(sp), index = sp, catchRate = tonumber(meta.catchRate), expYield = tonumber(meta.expYield), growthRate = tonumber(meta.growthRate), } end local function nameList(list, toName) if type(list) ~= "table" then return list end local out = {} for _, v in ipairs(list) do local name = (tonumber(v) or 0) ~= 0 and toName(v) or nil if name then out[#out + 1] = name end end return out end function Gen3Compat.partyNames(party) local out = {} for i, mon in ipairs(party or {}) do local row = {} for k, v in pairs(mon) do row[k] = v end local sp = tonumber(mon.species or mon.speciesId) row.species = Gen3Compat.speciesName(sp) or mon.species row.speciesId = sp if type(mon.moves) == "table" then row.moves = nameList(mon.moves, Gen3Compat.moveName) row.moveIds = {} for j, v in ipairs(mon.moves) do row.moveIds[j] = v end end if mon.heldItem ~= nil then row.heldItem = Gen3Compat.itemName(mon.heldItem) row.heldItemId = tonumber(mon.heldItem) end out[i] = row end return out end function Gen3Compat.partyNums(party) local out = {} for i, mon in ipairs(party or {}) do local row = {} for k, v in pairs(mon) do row[k] = v end row.species = Gen3Compat.speciesId(mon.species) or tonumber(mon.speciesId) or 0 row.speciesId = nil if type(mon.moves) == "table" then local list = {} for j, v in ipairs(mon.moves) do list[j] = Gen3Compat.moveId(v) or 0 end row.moves = list end row.moveIds = nil if mon.heldItem ~= nil then row.heldItem = Gen3Compat.itemId(mon.heldItem) end row.heldItemId = nil out[i] = row end return out end -- ------- live views over the session local function flagId(name) if type(name) == "number" then return name end if type(name) ~= "string" then return nil end local n = tonumber(name) if n then return n end local Flags = g3("scripting.flags") if not Flags then return nil end local ok, t = pcall(Flags.active, session()) local ids = ok and t and t.IDS or Flags.IDS return ids and ids[name] or nil end local function varId(name) if type(name) == "number" then return name end if type(name) ~= "string" then return nil end local n = tonumber(name) if n then return n end local Flags = g3("scripting.flags") if not Flags then return nil end local ok, t = pcall(Flags.active, session()) local ids = ok and t and t.VAR_IDS or Flags.VAR_IDS return ids and ids[name] or nil end function Gen3Compat.getFlag(name) local id = flagId(name) if not id then return nil end local Flags = g3("scripting.flags") local store = flagStore() if store and Flags then return Flags.getFlag(store, nil, id) and true or nil end local s = session() local flags = s and s.flags if not flags then return nil end return (flags[id] or flags[tostring(id)]) and true or nil end function Gen3Compat.setFlag(name, value) local id = flagId(name) if not id then return nil, "unknown FireRed flag: " .. tostring(name) end local Flags = g3("scripting.flags") local store = flagStore() if store and Flags then Flags.setFlag(store, nil, id, value and true or false) local Objects = package.loaded["src.core.game3.objects"] if Objects and Objects.syncFlagVisibility then Objects.syncFlagVisibility(id, value and true or false, true) end return true end local s = session() if not s then return nil, "no session" end s.flags = s.flags or {} s.flags[tostring(id)] = value and true or nil return true end function Gen3Compat.getVar(name) local id = varId(name) if not id then return nil end local Flags = g3("scripting.flags") local S = space() if S and S.store and Flags then return Flags.getVar(S.store, S.vm and S.vm.ctx, id) end local s = session() local vars = s and s.vars return vars and tonumber(vars[id] or vars[tostring(id)]) or 0 end function Gen3Compat.setVar(name, value) local id = varId(name) if not id then return nil, "unknown FireRed var: " .. tostring(name) end local Flags = g3("scripting.flags") local S = space() if S and S.store and Flags then Flags.setVar(S.store, S.vm and S.vm.ctx, id, value) return true end local s = session() if not s then return nil, "no session" end s.vars = s.vars or {} s.vars[tostring(id)] = tonumber(value) or 0 return true end local FLAGS_VIEW = setmetatable({}, { __index = function(_, key) return Gen3Compat.getFlag(key) end, __newindex = function(_, key, value) local ok, err = Gen3Compat.setFlag(key, value) if not ok then warnOnce("save.flags." .. tostring(key), "[%s] save.flags.%s: %s", who("src.core.Game"), tostring(key), tostring(err)) end end, }) local VARS_VIEW = setmetatable({}, { __index = function(_, key) return Gen3Compat.getVar(key) end, __newindex = function(_, key, value) Gen3Compat.setVar(key, value) end, }) local INVENTORY_VIEW = setmetatable({}, { __index = function(_, key) local s = session() local Bag = g3("bag") if not (s and s.bag and Bag) then return nil end local id = Gen3Compat.itemId(key) or key local n = Bag.get(s.bag, id) if not n or n <= 0 then return nil end return n end, __newindex = function(_, key, value) local s = session() local Bag = g3("bag") if not (s and s.bag and Bag) then return end local id = Gen3Compat.itemId(key) if not id then warnOnce("save.inventory." .. tostring(key), "[%s] save.inventory.%s: no FireRed item of that name", who("src.core.Game"), tostring(key)) return end Bag.set(s.bag, id, tonumber(value) or 0) end, }) local function dexSide(field) return setmetatable({}, { __index = function(_, key) local s = session() local dex = s and s.dex and s.dex[field] local sp = Gen3Compat.speciesId(key) if not (dex and sp) then return nil end return (dex[sp] or dex[tostring(sp)]) and true or nil end, __newindex = function(_, key, value) local s = session() local sp = Gen3Compat.speciesId(key) if not (s and sp) then return end s.dex = s.dex or {} s.dex[field] = s.dex[field] or {} s.dex[field][sp] = value and true or nil end, }) end local POKEDEX_VIEW = { seen = dexSide("seen"), caught = dexSide("owned") } local PLAYER_VIEW = setmetatable({}, { __index = function(_, key) local s = session() if not s then return nil end if key == "name" then return s.name end if key == "rival" then return s.rivalName end if key == "money" then return s.money end if key == "map" then return Gen3Compat.gen1MapId(s.map) end if key == "gen3Map" then return s.map end if key == "x" or key == "y" or key == "facing" then local P = package.loaded["src.core.game3.player"] if P and inField() then if key == "x" then return P.cellX end if key == "y" then return P.cellY end return P.facing end return s[key] end return nil end, __newindex = function(_, key, value) local s = session() if not s then return end if key == "name" then s.name = value elseif key == "rival" then s.rivalName = value elseif key == "money" then s.money = value else warnOnce("save.player.write." .. tostring(key), "[%s] save.player.%s is read-only on FireRed; move the player with " .. "mod.world:warpTo", who("src.core.Game"), tostring(key)) end end, }) local SAVE_ABSENT = { boxes = "FireRed's PC is session.storage (14 boxes of 30 sparse slots); " .. "require src.pokemon.Boxes, which is served", box = "FireRed has no Gen 1 save.box", objectToggles = "an object's visibility IS its hide flag on FireRed", defeatedTrainers = "trainer defeats are flags 0x500 + trainer id", hiddenTaken = "hidden items are flags on FireRed", } local SAVE_VIEW = setmetatable({}, { __index = function(_, key) local s = session() if not s then return nil end if key == "flags" then return FLAGS_VIEW end if key == "vars" then return VARS_VIEW end if key == "inventory" then return INVENTORY_VIEW end if key == "player" then return PLAYER_VIEW end if key == "pokedex" then return POKEDEX_VIEW end if key == "gen3" then return s end local why = SAVE_ABSENT[key] if why then warnOnce("save.absent." .. key, "[%s] save.%s has no Gen 3 backing: %s", who("src.core.Game"), key, why) return nil end return s[key] end, __newindex = function(_, key, value) local s = session() if not s then return end if key == "flags" or key == "vars" or key == "inventory" or key == "player" or key == "pokedex" then warnOnce("save.replace." .. key, "[%s] save.%s cannot be replaced wholesale on FireRed; write its keys", who("src.core.Game"), key) return end s[key] = value end, }) Gen3Compat.saveView = SAVE_VIEW -- ------- data views local recordCache = setmetatable({}, { __mode = "k" }) local function pokemonRecord(ref) local P = g3("pokemon") local sp = Gen3Compat.speciesId(ref) if not (P and sp) then return nil end local names = P._names if not names and P.install then pcall(P.install, P._cache) names = P._names end if not (names and names[sp]) then return nil end local byModule = recordCache[P] if not byModule or byModule.names ~= names then byModule = { names = names, rows = {} } recordCache[P] = byModule end local hit = byModule.rows[sp] if hit then return hit end local base = P.stats and P.stats(sp) or {} local meta = P.speciesMeta and P.speciesMeta(sp) or {} local root = "data/generated/gba/pokemon/" hit = { id = (P.keyName and P.keyName(sp)) or canonicalName(names[sp]), index = sp, gen3Species = sp, name = names[sp], types = P.types and P.types(sp) or nil, baseStats = { hp = base.hp, attack = base.atk, defense = base.def, speed = base.spe, specialAttack = base.spa, specialDefense = base.spd }, catchRate = tonumber(meta.catchRate), growthRate = P.growthRate and P.growthRate(sp) or nil, abilities = P.abilities and P.abilities(sp) or nil, learnset = P.learnset and P.learnset(sp) or nil, evolutions = P.evolutions and P.evolutions(sp) or nil, dexEntry = P.dexEntry and P.dexEntry(sp) or nil, national = P.national and P.national(sp) or nil, spriteFront = root .. "front/" .. sp .. ".rgba", spriteBack = root .. "back/" .. sp .. ".rgba", trueColor = true, } byModule.rows[sp] = hit return hit end Gen3Compat.pokemonRecord = pokemonRecord local function moveRecord(ref) local M = g3("battle.moves") local num = Gen3Compat.moveId(ref) if not (M and num and M.get) then return nil end local row = M.get(num) if not row then return nil end local out = {} for k, v in pairs(row) do out[k] = v end out.index = num out.name = out.name or (M.displayName and M.displayName(num)) return out end Gen3Compat.moveRecord = moveRecord local function itemRecord(ref) local D = g3("items_data") local num = Gen3Compat.itemId(ref) if not (D and num) then return nil end return D.info(num) end Gen3Compat.itemRecord = itemRecord local function registryOverride(name, id) local g = live() local content = g and g.mods and g.mods.content local reg = content and content[name] if reg and reg.ops and type(id) == "string" and reg.ops[id] then return reg:get(id) end return nil end local function namedView(registry, record) return setmetatable({}, { __index = function(_, key) local over = registryOverride(registry, key) if over ~= nil then return over end return record(key) end, __newindex = function(_, key) warnOnce("data." .. registry .. ".write", "[%s] game.data.%s.%s: FireRed's %s are numeric ROM tables; change " .. "them through mod.content.%s", who("src.core.Game"), registry, tostring(key), registry, registry) end, }) end local POKEMON_VIEW = namedView("pokemon", pokemonRecord) local MOVES_VIEW = namedView("moves", moveRecord) local ITEMS_VIEW = namedView("items", itemRecord) local DATA_RENAMES = { encounters = "gen3Encounters", trainers = "gen3Trainers", text = "gen3Text", map_scripts = "gen3Scripts", scripts = "gen3Scripts", } local DATA_UNBACKED = { sprites = "FireRed's overworld sprites are baked by the extractor, not a " .. "data.sprites registry", field = "FireRed's field rules are src/core/game3/collision.lua and the " .. "ROM's metatile behaviours, not a data.field record", constants = "no Gen 1 rule table exists on FireRed", text_pointers = "the extractor's Gen 1 pointer table has no FireRed " .. "counterpart", trainer_headers = "the extractor's Gen 1 header table has no FireRed " .. "counterpart", palettes = "FireRed palettes are baked into the extracted RGBA art", icons = "FireRed icons are src/core/game3/pokemon.lua Pokemon.icon", battle_anims = "FireRed's battle animations are the pret script VM under " .. "src/core/game3/battle/anim_*", type_chart = "FireRed's type chart is src/core/game3/battle/types.lua", } local dataProxies = setmetatable({}, { __mode = "kv" }) local function mapsView(data) return setmetatable({}, { __index = function(_, key) local maps = data.maps if not maps then return nil end local hit = maps[key] if hit ~= nil then return hit end if type(key) == "string" then return maps[MAP_PREFIX .. key] end return nil end, __newindex = function(_, key, value) if data.maps then data.maps[Gen3Compat.gen3MapId(key) or key] = value end end, }) end local function dataProxy(data) if not data then return nil end local hit = dataProxies[data] if hit then return hit end local maps = mapsView(data) hit = setmetatable({}, { __index = function(_, key) if key == "pokemon" then return POKEMON_VIEW end if key == "moves" then return MOVES_VIEW end if key == "items" then return ITEMS_VIEW end if key == "maps" then return maps end local renamed = DATA_RENAMES[key] if renamed then return data[renamed] end local why = DATA_UNBACKED[key] if why then warnOnce("data." .. key, "[%s] game.data.%s is Gen 1 only: %s", who("src.core.Game"), key, why) return nil end return data[key] end, __newindex = function(_, key, value) data[DATA_RENAMES[key] or key] = value end, }) dataProxies[data] = hit return hit end Gen3Compat.dataView = dataProxy -- ------- src.core.Game local GAME_UNBACKED = { renderer = "FireRed composites through src/core/game3/display.lua at " .. "240x160 and never inits the Renderer singleton", load = "calling it would re-run FireRed's whole boot on top of a running " .. "game", bootConfig = "FireRed's new-game inputs are Schema.newGame's opts", makeTitleState = "src/ui/game3/boot.lua owns the title; nothing returns a " .. "state to push", step = "FireRed's logic tick is Game3:fixedUpdate; the input.step hook is " .. "the replacement", restoreSave = "FireRed continues through the boot menu " .. "(Game3:_handleBootAction), not a save table handed in", linkNet = "FireRed has no link play", linkSession = "FireRed has no link play", } local GAME_STACK_STATICS = { "worldBgBattleDim", "worldBgBattleInStack", "fillScaleInStack", "wideBattleInStack", "uiAnchorsHeldInStack", "drawBaseInStack", "dynamicUI", } local function buildGame() local proxy = {} local statics = nil local function gen1Static(key) if statics == nil then local ok, module = pcall(rawRequire, "src.core.Game") statics = ok and module or false end return statics and statics[key] or nil end local translate = {} function translate.overworld() if not inField() then return nil end return Gen3Compat.resolve("src.world.OverworldController") end function translate.data(g) return dataProxy(g.data) end function translate.save() if not session() then return nil end return SAVE_VIEW end function translate.stack() warnOnce("game.stack", "[%s] Game.stack on FireRed is src/ui/game3/stack.lua: push(id, module, " .. "opts) / pop(id) / top(), not a StateStack of state objects", who("src.core.Game")) return ui3("stack") end function translate.fixedStep() return rawRequire("src.core.FixedStep") end function translate.generation() return 3 end function translate.writeOptions() return function() local g = live() if g and g.writeOptions then return g:writeOptions() end end end function translate.logicSpeed() return function() local g = live() if not g or not g.logicSpeed then return 1 end return g:logicSpeed() end end function translate.applyOptions() return function(opts) local g = live() if not g or not g.applyOptions then return end if opts == proxy then opts = nil end return g:applyOptions(opts or g.options) end end function translate.saveGame() return function() local g = live() if not (g and g.saveGame) then return end if g.saveOffered and not g:saveOffered() then return false end return g:saveGame() end end function translate.writeSave() return translate.saveGame() end function translate.restartWithMods() return function() return rawRequire("src.core.HostShell").restart() end end function translate.recoverInput() return function() local g = live() if not g then return end local Input = rawRequire("src.core.Input") Input:reset() if Input.reconcile then Input:reconcile() end local okT, Touch = pcall(rawRequire, "src.core.TouchControls") if okT and Touch and Touch.reset then Touch:reset() end if g.mods and g.mods.releaseModInput then g.mods:releaseModInput() end end end function translate.zoomStep() return function(delta) local g = live() if g and g.zoomStep then g:zoomStep(delta) end end end local LIVE_FREE = { writeOptions = true, logicSpeed = true, fixedStep = true, restartWithMods = true, generation = true } setmetatable(proxy, { __index = function(_, key) local why = GAME_UNBACKED[key] if why then warnOnce("game." .. key, "[%s] Game.%s has no Gen 3 backing: %s", who("src.core.Game"), key, why) return nil end local made = translate[key] if made then local g = live() if not g and not LIVE_FREE[key] then return nil end return made(g) end for _, name in ipairs(GAME_STACK_STATICS) do if name == key then return gen1Static(key) end end local g = live() if not g then return nil end local value = g[key] if value == nil then warnOnce("game.unknown." .. tostring(key), "[%s] Game.%s is not on the FireRed service owner; it reads nil " .. "because Game3 has no member of that name", who("src.core.Game"), tostring(key)) return nil end if type(value) == "function" then return function(first, ...) if first == proxy then return value(g, ...) end return value(first, ...) end end return value end, __newindex = function(_, key, value) local g = live() if not g then return end if key == "overworld" or key == "save" or key == "data" then warnOnce("game.write." .. key, "[%s] Game.%s cannot be replaced on FireRed", who("src.core.Game"), key) return end g[key] = value end, }) return proxy end COVERAGE["src.core.Game"] = { kind = "facade", target = "src.core.Game3", backed = "overworld data data.pokemon data.moves data.items data.maps " .. "data.encounters data.trainers data.text data.map_scripts data.tilesets " .. "save save.flags save.vars save.party save.money save.inventory " .. "save.player save.player.map save.player.x save.player.y " .. "save.player.facing save.player.rival save.pokedex save.options " .. "save.modData mods modStatus input touchControls options phase " .. "fixedStep generation writeOptions logicSpeed applyOptions saveGame " .. "writeSave restartWithMods recoverInput zoomStep returnToTitle update " .. "draw keypressed keyreleased gamepadpressed gamepadreleased " .. "gamepadaxis joystickpressed joystickreleased joystickaxis joystickhat " .. "joystickremoved focus visible onResume touchpressed touchmoved " .. "touchreleased mousepressed mousemoved mousereleased wheelmoved " .. "worldBgBattleDim worldBgBattleInStack fillScaleInStack " .. "wideBattleInStack uiAnchorsHeldInStack drawBaseInStack dynamicUI", warned = "stack renderer load bootConfig makeTitleState step restoreSave " .. "linkNet linkSession data.sprites data.field data.constants " .. "data.text_pointers data.trainer_headers data.palettes data.icons " .. "data.battle_anims data.type_chart save.boxes save.box " .. "save.objectToggles save.defeatedTrainers save.hiddenTaken", absent = "saveReport save.meta", notes = { identity = "the proxy never compares equal to the Game3 instance in the " .. "game.ready payload; mod.game is the raw Game3", iteration = "pairs/next/rawget see an EMPTY table on the proxy and on " .. "every save / data view below it", save = "a LIVE view over the FireRed session (Runtime.getSession), not " .. "Game3.save, which is a snapshot rebuilt at each save", ["save.flags"] = "keyed by FireRed flag NAME (FLAG_SYS_POKEMON_GET, or " .. "the name without FLAG_) or number; a Gen 1 name such as " .. "EVENT_GOT_STARTER reads nil because FireRed has no such flag", ["save.inventory"] = "item name or FireRed item id -> count; answered " .. "through src/core/game3/bag.lua, so a write lands in the right pocket", ["save.player.map"] = "the Gen 1 spelling (FR_ prefix stripped); " .. "save.player.gen3Map is the raw id", ["save.pokedex"] = "seen / caught keyed by species name or FireRed " .. "species number", ["data.pokemon"] = "a NAME-keyed view assembled from the numeric FireRed " .. "tables; spriteFront/spriteBack name the cache .rgba, and a " .. "registry override wins", ["data.maps"] = "keyed by the FR_ id; a Gen 1 spelling without the " .. "prefix also resolves", stack = "src/ui/game3/stack.lua, a module-level id stack, warned once", applyOptions = "Game3:applyOptions(opts) takes the table directly, the " .. "same contract as Gen 1", }, } -- ------- src.world.Map local MapView = {} local connViews = setmetatable({}, { __mode = "k" }) local function collision() return package.loaded["src.core.game3.collision"] end function MapView.__index(self, key) local method = rawget(MapView, key) if method ~= nil then return method end local raw = rawget(self, "gen3Id") local g = live() local def = g and g.data and g.data.maps and g.data.maps[raw] if key == "def" then return def end if key == "tileset" then return def and def.tileset end if key == "widthCells" then return def and def.width end if key == "heightCells" then return def and def.height end if key == "warps" then return def and def.warps end if key == "connections" then if not def then return nil end local hit = connViews[def] if hit and hit.src == def.connections then return hit.view end local list = require("src.core.game3.connections").each(def) local view = setmetatable(list, { __index = function(t, dir) if type(dir) ~= "string" then return nil end for i = 1, #t do if t[i].dir == dir then return t[i] end end return nil end }) connViews[def] = { src = def.connections, view = view } return view end return nil end local function isActiveMap(self) local M = package.loaded["src.core.game3.map"] return M and M.current == rawget(self, "gen3Id") end function MapView:inBounds(cx, cy) local C = collision() if not (C and isActiveMap(self)) then return false end return C.inBounds(cx, cy) and true or false end function MapView:isWalkableCell(cx, cy) local C = collision() if not (C and isActiveMap(self)) then return false end return C.isWalkable(cx, cy) and true or false end function MapView:isWaterCell(cx, cy) local C = collision() if not (C and isActiveMap(self)) then return false end return C.isWater(cx, cy) and true or false end function MapView:isGrassCell(cx, cy) local C = collision() if not (C and isActiveMap(self)) then return false end return C.isGrass(cx, cy) and true or false end function MapView:warpAtCell(cx, cy) local C = collision() if not (C and isActiveMap(self)) then return nil end return C.warpAt(cx, cy) end function MapView:cellBehavior(cx, cy) local C = collision() if not (C and isActiveMap(self)) then return nil end return C.behavior(cx, cy) end function MapView:isOutdoor() local def = self.def local pair = def and (def.pair or (def.midLayout and def.midLayout.pair)) return type(pair) == "string" and pair:find("outdoor", 1, true) ~= nil end MapView.isOutside = MapView.isOutdoor function MapView.new() warnOnce("map.new", "[%s] src.world.Map.new: FireRed maps are loaded by " .. "src/core/game3/map.lua Map.load; take the live one from " .. "OverworldController.map", who("src.world.Map")) return nil end local function mapView(rawId) if not rawId then return nil end return setmetatable({ id = Gen3Compat.gen1MapId(rawId), gen3Id = rawId }, MapView) end Gen3Compat.mapView = mapView local function buildMap() return MapView end COVERAGE["src.world.Map"] = { kind = "facade", target = "src.core.game3.map", backed = "id gen3Id def tileset widthCells heightCells warps connections " .. "inBounds isWalkableCell isWaterCell isGrassCell warpAtCell " .. "cellBehavior isOutdoor isOutside", warned = "new", absent = "cellTile isCounterCell blockAt setBlock tileAt isDoorTileCell " .. "isWarpTileCell signAtCell connection isPushable defCellTile " .. "defIsWalkableCell defIsWaterCell defPassable DELTA isFlyTown " .. "ghostBattles warpPadOrHoleAt walkable doorTiles warpTiles waterTiles " .. "renderer signAt warpAt", notes = { id = "the Gen 1 spelling (FR_OAKS_LAB -> OAKS_LAB); gen3Id is the raw id", inBounds = "cell queries answer only for the ACTIVE map: FireRed binds one " .. "collision grid at a time (src/core/game3/collision.lua)", blockAt = "FireRed has 16px metatiles and no 32px blocks", }, } -- ------- src.world.NPC local function buildNpc() local NPC = {} NPC.new = unbacked("src.world.NPC", "new", "FireRed event objects are plain records built by " .. "src/core/game3/objects.lua from the map's object templates; " .. "mod.world:npc hands out a handle onto a live one") function NPC.facePlayer(npc) local Objects = package.loaded["src.core.game3.objects"] if not (Objects and type(npc) == "table" and npc.localId) then return nil end return Objects.facePlayer(npc.localId, live()) end function NPC.pose(npc) if type(npc) ~= "table" then return nil end return npc.facing, npc.moving end return NPC end COVERAGE["src.world.NPC"] = { kind = "facade", target = "src.core.game3.objects", backed = "facePlayer pose", warned = "new", absent = "update walkPhase draw hopStep MOVE __index", notes = { instance = "a live FireRed object carries localId cellX cellY px py " .. "facing moving progress frozen passable sprite def; it has no " .. "metatable, so getmetatable(npc) == NPC is false", new = "no constructor: FireRed object templates come from the extracted " .. "map events", }, } -- ------- src.world.Collision local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } local function buildCollision() local Collision = { DELTA = DELTA } function Collision.target(cx, cy, dir) local d = DELTA[dir] if not d then return cx, cy end return cx + d[1], cy + d[2] end function Collision.occupied(entities, cx, cy, ignore) for _, e in ipairs(entities or {}) do if e ~= ignore and not e.passable then if (e.cellX == cx and e.cellY == cy) or (e.targetX == cx and e.targetY == cy) then return e end end end return nil end function Collision.load(_data) warnOnce("collision.load", "[%s] Collision.load: FireRed has no data.field.tilePairs; passability " .. "is the ROM's metatile behaviour (src/core/game3/collision.lua)", who("src.world.Collision")) return nil end local function passthrough(allowed) return allowed end function Collision.canMove(map, entities, mover, dir) local tx, ty = Collision.target(mover.cellX, mover.cellY, dir) local C = collision() local allowed, why = true, nil if not C then allowed, why = false, "bounds" else -- pokefirered/src/event_object_movement.c:4830 GetCollisionAtCoords local ok, reason = C.canEnter(live(), tx, ty, { fromX = mover.cellX, fromY = mover.cellY, dir = dir, surfing = mover.surfing and true or false }) if not ok then allowed = false why = (reason == "bounds" or reason == "entity") and reason or "tile" elseif Collision.occupied(entities, tx, ty, mover) then allowed, why = false, "entity" end end if Runtime.wantsHook("movement.collision") then local ctx = { map = map, mover = mover, dir = dir, fromX = mover.cellX, fromY = mover.cellY, toX = tx, toY = ty, reason = why } allowed = Runtime.call("movement.collision", passthrough, allowed, ctx) why = ctx.reason end if allowed then return true end return false, why end return Collision end COVERAGE["src.world.Collision"] = { kind = "facade", target = "src.core.game3.collision", backed = "DELTA target occupied canMove", warned = "load", absent = "", notes = { canMove = "the verdict src/core/game3/collision.lua Collision.canEnter " .. "gives the active map, so `map` is ignored and only the loaded grid " .. "answers", target = "returns (cx, cy) unchanged for an unknown dir where Gen 1 errors", }, } -- ------- src.world.FieldDefaults local WORLD_CONSTANTS = { stepFrames = 16, turnFrames = 4, runStepFrames = 8, bikeStepFrames = 4, } local function buildFieldDefaults() local FieldDefaults = { CONSTANTS = { world = WORLD_CONSTANTS } } function FieldDefaults.field(_data, key) warnOnce("fieldDefaults.field." .. tostring(key), "[%s] src.world.FieldDefaults.field(%s): FireRed has no data.field", who("src.world.FieldDefaults"), tostring(key)) return nil end function FieldDefaults.fieldValue(_data, key, ...) local path = tostring(key) for i = 1, select("#", ...) do path = path .. "." .. tostring((select(i, ...))) end warnOnce("fieldDefaults.fieldValue." .. path, "[%s] src.world.FieldDefaults: FireRed has no data.field %s", who("src.world.FieldDefaults"), path) return nil end function FieldDefaults.constant(_data, key) if key == "world" then return WORLD_CONSTANTS end warnOnce("fieldDefaults.constant." .. tostring(key), "[%s] FieldDefaults.constant(%s): the Gen 1 default is Kanto Red's rule " .. "and FireRed does not carry it", who("src.world.FieldDefaults"), tostring(key)) return nil end function FieldDefaults.world(_data, key) local value = WORLD_CONSTANTS[key] if value ~= nil then return value end warnOnce("fieldDefaults.world." .. tostring(key), "[%s] FieldDefaults.world(%s): FireRed's step events do not read a " .. "shared constant table", who("src.world.FieldDefaults"), tostring(key)) return nil end FieldDefaults.seed = unbacked("src.world.FieldDefaults", "seed", "seeding Kanto Red's FIELD into a FireRed dataset would put Gen 1 map ids " .. "into data.field") return FieldDefaults end COVERAGE["src.world.FieldDefaults"] = { kind = "facade", backed = "CONSTANTS CONSTANTS.world constant world", warned = "field fieldValue seed", absent = "FIELD CONSTANTS.encounterBuckets CONSTANTS.hmBadges", notes = { ["CONSTANTS.world"] = "stepFrames 16, turnFrames 4, runStepFrames 8, " .. "bikeStepFrames 4, the frame counts src/core/game3/player.lua uses", }, } -- ------- src.pokemon.Boxes local function sessionOf(save) if save == nil or save == SAVE_VIEW then return session() end if type(save) == "table" and save.storage ~= nil then return save end return session() end local function buildBoxes() local Storage = g3("storage") local Boxes = { COUNT = Storage and Storage.TOTAL_BOXES_COUNT or 14, CAPACITY = Storage and Storage.IN_BOX_COUNT or 30, } function Boxes.ensure(save) local s = sessionOf(save) if not (s and Storage) then return {} end local storage = Storage.ensure(s) local out = {} for i = 1, Boxes.COUNT do out[i] = storage.boxes[i] and storage.boxes[i].mons or {} end return out end function Boxes.active(save) local s = sessionOf(save) if not (s and Storage) then return nil end local storage = Storage.ensure(s) local box = storage.boxes[storage.currentBox or 1] return box and box.mons or nil end function Boxes.deposit(save, mon) local s = sessionOf(save) if not (s and Storage and mon) then return nil end local ok, boxId = Storage.depositCaught(s, mon) if not ok then return nil end return boxId end function Boxes.count(save, index) local s = sessionOf(save) if not (s and Storage) then return 0 end local storage = Storage.ensure(s) return Storage.countBoxMons(storage, index or storage.currentBox or 1) end function Boxes.isFull(save) local s = sessionOf(save) if not (s and Storage) then return true end return Storage.findOpenSlot(Storage.ensure(s)) == nil end function Boxes.setCurrent(save, index) local s = sessionOf(save) if not (s and Storage) then return nil end local storage = Storage.ensure(s) storage.currentBox = math.max(1, math.min(Boxes.COUNT, tonumber(index) or 1)) return storage.currentBox end return Boxes end COVERAGE["src.pokemon.Boxes"] = { kind = "facade", target = "src.core.game3.storage", backed = "COUNT CAPACITY ensure active deposit count isFull setCurrent", warned = "", absent = "withdraw release move name rename box", notes = { COUNT = "14 boxes on FireRed", CAPACITY = "30 slots per box on FireRed", ensure = "each box is session.storage.boxes[i].mons, a SPARSE 30-slot " .. "table: iterate 1..CAPACITY and skip nil, never trust #box", deposit = "Storage.depositCaught: first open slot from the current box, " .. "and depositing heals the mon as the Gen 3 PC does", }, } -- ------- src.world.OverworldController local overworld local function defaultUpdate() end local function defaultInteract() local Field = package.loaded["src.core.game3.field"] if Field and Field.interact then return Field.interact(live()) end return false end local function defaultTalkTo() return false end local OW = "src.world.OverworldController" local function objectsList() local Objects = package.loaded["src.core.game3.objects"] local out = {} if not (Objects and Objects._order and Objects._byId) then return out end for _, lid in ipairs(Objects._order) do local eo = Objects._byId[lid] if eo and eo.visible and not eo.hidden then out[#out + 1] = eo end end return out end local function playerModule() return package.loaded["src.core.game3.player"] end local function localIdOf(entity) local P = playerModule() local Objects = package.loaded["src.core.game3.objects"] if entity == nil then return nil end if entity == P then return Objects and Objects.PLAYER_LOCAL_ID or 0xFF end if type(entity) == "table" and entity.localId then return entity.localId end return nil end function Gen3Compat.worldBusy() local Field = package.loaded["src.core.game3.field"] if not (Field and Field.running) then return true, "no overworld" end if Field.locked then return true, "world is busy" end local R = package.loaded["src.core.game3.runtime"] if R and R.uiBusy then local ok, busy = pcall(R.uiBusy) if ok and busy then return true, "world is busy" end end local S = space() if S and S.vm and S.vm.isRunning and S.vm:isRunning() then return true, "a script is running" end local B = package.loaded["src.core.game3.battle.init"] or package.loaded["src.core.game3.battle"] if B and B.isActive and B.isActive() then return true, "a battle is running" end local W = package.loaded["src.core.game3.warp"] if W and W.isBusy and W.isBusy() then return true, "the world is mid-warp" end local P = playerModule() if P and P.moving then return true, "world is busy" end return false end local function buildOverworld() local ow = { update = defaultUpdate, interact = defaultInteract, talkTo = defaultTalkTo, } local function ready(member) if inField() then return true end warnOnce(OW .. "." .. member .. ".nofield", "[%s] %s.%s: no FireRed field is up yet; the honest replacement is the " .. "game.ready / map.entered event", who(OW), OW, member) return false end function ow.npcAtCell(cx, cy) if not ready("npcAtCell") then return nil end local Objects = package.loaded["src.core.game3.objects"] return Objects and Objects.at(cx, cy) or nil end function ow.npcByIndex(index) if not ready("npcByIndex") then return nil end local Objects = package.loaded["src.core.game3.objects"] if type(index) ~= "number" or not Objects then return nil end local eo = Objects._byId and Objects._byId[index] return eo end function ow.setMap(mapId, x, y, facing, _opts) if not ready("setMap") then return nil end local raw = Gen3Compat.gen3MapId(mapId) if not raw then return nil, "unknown map: " .. tostring(mapId) end local M = g3("map") return M and M.load(nil, live(), raw, { x = x, y = y, facing = facing or "down", depth1Connections = true }) or nil end function ow.reloadMap(mapId, _reason) if not ready("reloadMap") then return nil end local M = g3("map") local P = playerModule() if not (M and M.current and P) then return nil end local raw = mapId and Gen3Compat.gen3MapId(mapId) or M.current if raw ~= M.current then return true end M.load(nil, live(), raw, { x = P.cellX, y = P.cellY, facing = P.facing, depth1Connections = true }) Runtime.emit("map.reloaded", { mapId = raw, reason = "invalidate" }) return true end function ow.startWarpTo(mapId, x, y, facing, onDone, opts) if not ready("startWarpTo") then return nil end local raw = Gen3Compat.gen3MapId(mapId) if not raw then return nil, "unknown map: " .. tostring(mapId) end if onDone then warnOnce("ow.startWarpTo.onDone", "[%s] OverworldState.startWarpTo drops onDone on FireRed " .. "(src/core/game3/warp.lua Warp.request has no completion callback)", who(OW)) end local W = g3("warp") local P = playerModule() return W and W.request(nil, live(), raw, x, y, facing or (P and P.facing) or "down", { teleport = opts and opts.arrive == "teleport" or nil }) or nil end function ow.canCollisionWarp() local W = package.loaded["src.core.game3.warp"] return not (W and W.isBusy and W.isBusy()) end function ow.healPoint() local s = session() if not s then return nil end return { map = Gen3Compat.gen1MapId(s.healMap), gen3Map = s.healMap, x = s.healX, y = s.healY } end function ow.warpToHealPoint(onDone) if not ready("warpToHealPoint") then return nil end local Field = g3("field") if not Field then return nil end Field.respawnAtHeal({ fieldMove = true }) if onDone then onDone() end return true end function ow.stepForwardOrCrossEdge(dir) if not ready("stepForwardOrCrossEdge") then return nil end local P = playerModule() return P and P.tryMove(dir, live(), false) or nil end function ow.checkLedgeHop(dir) local C = collision() local P = playerModule() if not (C and P and inField()) then return false end return C.ledgeLanding(live(), P.cellX, P.cellY, dir) ~= nil end function ow.checkEdgeExit(dir) local C = collision() local P = playerModule() if not (C and P and inField()) then return false end return C.tryConnection(live(), P.cellX, P.cellY, dir, false) and true or false end ow.crossConnection = function(dir) return ow.checkEdgeExit(dir) end function ow.scriptMove(entity, dir, tiles, onDone, _opts) if not ready("scriptMove") then return nil, "no overworld" end local Objects = package.loaded["src.core.game3.objects"] if not Objects then return nil, "no overworld" end if not DELTA[dir] then return nil, "unknown direction: " .. tostring(dir) end local lid = localIdOf(entity) if not lid then return nil, "no FireRed localId for that entity: only the player and a " .. "live map object can be moved" end local actions = {} for _ = 1, math.max(0, tiles or 1) do actions[#actions + 1] = { kind = "step", dir = dir } end Objects.startTrack(lid, actions, onDone) return true end function ow.timeOfDay() warnOnce("ow.timeOfDay", "[%s] OverworldState.timeOfDay: FireRed has no clock and no time of " .. "day", who(OW)) return nil end function ow.replaceBlock(bx, by, block) if not ready("replaceBlock") then return nil end local Field = g3("field") if not Field then return nil end warnOnce("ow.replaceBlock", "[%s] OverworldState.replaceBlock on FireRed sets one 16px METATILE at " .. "(x, y) in metatile coordinates; FireRed has no 32px blocks", who(OW)) Field.setMetatile(bx, by, block) local M = package.loaded["src.core.game3.map"] Runtime.emit("world.block_replaced", { mapId = M and M.current, bx = bx, by = by, block = block }) return true end function ow.partyKnows(moveId) local s = session() local P = g3("pokemon") local num = Gen3Compat.moveId(moveId) if not (s and P and num) then return false end for _, mon in ipairs(s.party or {}) do if P.knowsMove(mon, num) then return true end end return false end function ow.openPC(onDone) if not ready("openPC") then return nil end local Hud = ui3("hud") if not Hud then return nil end if onDone then warnOnce("ow.openPC.onDone", "[%s] OverworldState.openPC drops onDone on FireRed: the PC closes " .. "itself with no callback", who(OW)) end Hud.openPc(live(), session()) return true end function ow.nurseHeal(onDone) local s = session() local Party = g3("party") if not (s and Party) then return nil end Party.healAll(s.party) if onDone then onDone() end return true end function ow.showMapText(key, _npc, onDone) if not ready("showMapText") then return nil end local S = space() local g = live() local body = (S and S.bundle and S.bundle.text and S.bundle.text[key]) or (g and g.data and g.data.gen3Text and g.data.gen3Text[key]) if body == nil then warnOnce("ow.showMapText." .. tostring(key), "[%s] OverworldState.showMapText(%s): FireRed text keys are the " .. "extracted script labels; a Gen 1 TEXT_* constant resolves to " .. "nothing", who(OW), tostring(key)) return nil end local Message = ui3("message") if not Message then return nil end Message.show(body, function() Message.close() if onDone then onDone() end end) return true end local queueApi function ow.queueScript(rows, extra) local g = live() if not g then return nil, "no overworld" end if not queueApi or queueApi.game ~= g then queueApi = rawRequire("src.world.game3.WorldAPI").new(g, OW) end return queueApi:queueScript(rows, extra) end local SEAMS = { update = true, interact = true, talkTo = true } for key, fn in pairs(ow) do if type(fn) == "function" and not SEAMS[key] then ow[key] = function(first, ...) if first == overworld then return fn(...) end return fn(first, ...) end end end local LIVE_FIELD = { map = true, player = true, npcs = true, entities = true } setmetatable(ow, { __index = function(_, key) if not LIVE_FIELD[key] then if key == "isOverworld" then return true end return nil end if not inField() then return nil end if key == "map" then local M = package.loaded["src.core.game3.map"] return mapView(M and M.current or (session() and session().map)) end if key == "player" then return playerModule() end local list = objectsList() if key == "entities" then local P = playerModule() if P then list[#list + 1] = P end end return list end, __newindex = function(t, key, value) if LIVE_FIELD[key] then warnOnce("ow.write." .. key, "[%s] OverworldState.%s is owned by src/core/game3 and cannot be " .. "replaced; move the player with warpTo", who(OW), key) return end rawset(t, key, value) end, }) overworld = ow return ow end function Gen3Compat.worldTick(dt) if not overworld or overworld.update == defaultUpdate then return end overworld.update(overworld, dt) end function Gen3Compat.interactWrapper() if not overworld or overworld.interact == defaultInteract then return nil end return overworld.interact end function Gen3Compat.talkToWrapper() if not overworld or overworld.talkTo == defaultTalkTo then return nil end return overworld.talkTo end COVERAGE[OW] = { kind = "facade", target = "src.core.game3.field", backed = "map player npcs entities isOverworld npcAtCell npcByIndex setMap " .. "reloadMap startWarpTo canCollisionWarp healPoint warpToHealPoint " .. "stepForwardOrCrossEdge checkLedgeHop checkEdgeExit crossConnection " .. "scriptMove partyKnows openPC nurseHeal showMapText queueScript " .. "update interact talkTo", warned = "timeOfDay replaceBlock startWarpTo openPC", absent = "pushableAtCell pooledNPC computeNeighbors rebuildNeighbors " .. "takeWarp refreshStandingOnWarp dirHeld handleInput connectionLanding " .. "checkBoulderPush checkForcedMovement updateScriptMoves setDark " .. "bikeAllowed addRuntimeObject removeRuntimeObject tryHiddenObject " .. "hasHiddenItemLeft facingIsShoreOrWater facingIsLandDismount trySurf " .. "tryCut goFishing flyTo trainerDefeated engageTrainer " .. "checkTrainerSight startTrainerApproach applyFieldPoison neighbors " .. "ghosts npcPool camera runner tod dark draw", notes = { map = "a view with the Gen 1 id (FR_ stripped) plus gen3Id; the cell " .. "queries answer against the active collision grid", player = "src/core/game3/player.lua itself: cellX cellY px py facing " .. "moving surfing biking carry, and writes land on the live avatar", npcs = "a fresh array per read of the visible FireRed event objects", update = "a replacement runs each field tick via Gen3Compat.worldTick", interact = "a replacement owns the A press; the original is Field.interact", talkTo = "a replacement returning true consumes a scripted NPC talk", startWarpTo = "Warp.request with the FireRed fade; onDone is dropped", replaceBlock = "sets one 16px metatile (Field.setMetatile), not a 32px " .. "Gen 1 block", npcByIndex = "keyed by the FireRed localId the map's object template " .. "carries", timeOfDay = "FireRed has no clock", flyTo = "the FireRed fly map is src/ui/game3/region_map.lua; no seam", }, } -- ------- src.world.PikachuFollower local function buildFollower() return require("src.world.game3.Follower") end COVERAGE["src.world.PikachuFollower"] = { kind = "facade", backed = "current starterInParty talk setShouldSpawn onMapEntered update setVisible at", warned = "", absent = "shouldSpawn rebase SPRITE onStep bumpHappiness modifyHappiness " .. "picLift hopToCounter updateHop", notes = { current = "optional mod companion; absent until setShouldSpawn enables it", talk = "returns false and never calls done", }, } -- ------- UI facades local PATCHED_NIL = {} local function passThroughProxy(target, overrides, absent, moduleName) local patched = {} return setmetatable({}, { __index = function(_, key) local mine = patched[key] if mine == PATCHED_NIL then return nil end if mine ~= nil then return mine end local made = overrides[key] if made ~= nil then return made end local why = absent[key] if why then warnOnce(moduleName .. "." .. key, "[%s] %s.%s has no Gen 3 backing: %s", who(moduleName), moduleName, key, why) return nil end return target[key] end, __newindex = function(_, key, value) patched[key] = (value == nil) and PATCHED_NIL or value target[key] = value end, }) end local UI_ABSENT = "FireRed's screens are module singletons under src/ui/game3 " .. "drawn at 240x160; there is no Gen 1 state object to reach into" local function buildPartyMenu() local PM = ui3("party_menu") or {} local overrides, absent = {}, {} local showOrig, closeOrig = PM.show, PM.close for _, name in ipairs({ "drawIcon", "frameFor", "mirrorsIcon", "iconFrames", "sgbPalettes", "animateTo", "entryY" }) do absent[name] = UI_ABSENT end function overrides.new(_game, opts) opts = opts or {} local s = session() if not (s and showOrig) then return nil end local handle = {} function handle.close() if closeOrig then closeOrig() end end handle.party = opts.party or s.party showOrig(handle.party, s.move_overlay, { session = s, mode = "list", onSelect = opts.onSwitch and function(slot) local mon = slot and handle.party[slot] if mon then opts.onSwitch(mon, handle) end end or nil, onClose = opts.onCancel, }) return handle end function overrides.close() if closeOrig then return closeOrig() end end return passThroughProxy(PM, overrides, absent, "src.ui.PartyMenu") end COVERAGE["src.ui.PartyMenu"] = { kind = "facade", target = "src.ui.game3.party_menu", backed = "new close show isOpen update draw handleInput", warned = "", absent = "drawIcon frameFor mirrorsIcon iconFrames sgbPalettes animateTo " .. "entryY bottomMessage index submenu", notes = { new = "opens the FireRed party screen over session.party and returns a " .. "handle { party, close }, not a state object; onSwitch(mon, handle) " .. "and onCancel are honoured, the rest of opts is not", }, } local function buildStartMenu() local SM = ui3("start_menu") or {} local overrides = {} function overrides.new(_game) local Hud = ui3("hud") if not (Hud and inField()) then return nil end if not (SM.isOpen and SM.isOpen()) then Hud.openStartMenu(live(), session()) end return SM end return passThroughProxy(SM, overrides, {}, "src.ui.StartMenu") end COVERAGE["src.ui.StartMenu"] = { kind = "facade", target = "src.ui.game3.start_menu", backed = "new show close isOpen draw ENTRIES", warned = "", absent = "items ITEMS lastIndex", notes = { new = "Hud.openStartMenu, which closes any other field menu first; " .. "returns the FireRed start menu module", ENTRIES = "the assembled list, rebuilt on every show(); the hook below " .. "hands it back and a table return replaces it", ["hook ui.start_menu.items"] = "raised from src/ui/game3/start_menu.lua, " .. "same name and arity as Gen 1 and Gold, but a FireRed row is " .. "{ id, label } and carries no onSelect to rewire, and a non-table " .. "return is dropped without the Logger.error the other two emit", }, } local function buildOptionsMenu() local OM = ui3("option_menu") or {} local overrides = {} local showOrig = OM.show function overrides.new(_game, opts) opts = opts or {} if not showOrig then return nil end showOrig({ session = session(), game = live(), onClose = opts.onCancel }) return OM end return passThroughProxy(OM, overrides, { sgbPalettes = UI_ABSENT }, "src.ui.OptionsMenu") end COVERAGE["src.ui.OptionsMenu"] = { kind = "facade", target = "src.ui.game3.option_menu", backed = "new show close isOpen draw handleInput", warned = "", absent = "sgbPalettes rows index scroll", notes = { new = "OptionMenu.show with opts.onCancel as onClose", }, } local function buildBoxMenu() local PC = ui3("pc_menu") or {} local overrides = {} function overrides.new(_game) local Hud = ui3("hud") if not (Hud and inField()) then return nil end Hud.openPc(live(), session()) return PC end return passThroughProxy(PC, overrides, {}, "src.ui.BoxMenu") end COVERAGE["src.ui.BoxMenu"] = { kind = "facade", target = "src.ui.game3.pc_menu", backed = "new show close isOpen draw handleInput", warned = "", absent = "items th tx tw scroll clampScroll", notes = { new = "Hud.openPc: Bill's / the player's PC top menu, the Gen 1 BoxMenu's " .. "counterpart", }, } -- ------- src.battle.BattleState local function battleModule() return package.loaded["src.core.game3.battle.init"] or package.loaded["src.core.game3.battle"] end local function buildBattleState() local B = {} local MOVED = "FireRed's battle engine is src/core/game3/battle, over pret's " .. "battle_main.c; no Gen 1 BattleState object exists" local absent = {} for _, name in ipairs({ "newWild", "newTrainer", "makeSafari", "makeGhost", "makeBattler", "resolveTurn", "executeAction", "performMove", "computeDamage", "catchAttempt", "runRoll", "enter", "exit", "sgbPalettes", "awardExp", "throwBall", "storeCaughtMon", "tryRun", }) do absent[name] = MOVED end function B.isActive() local M = battleModule() return M and M.isActive and M.isActive() or false end function B.current() local M = battleModule() return M and M.isActive and M.isActive() and M.getState and M.getState() or nil end function B.say(_self, text) local Ui = package.loaded["src.core.game3.battle.ui"] if not (Ui and Ui.push and B.isActive()) then return nil end Ui.push(tostring(text or "")) return true end B.sayAuto = B.say local LIVE = { player = true, enemy = true, turnCount = true, phase = true, result = true, kind = true, wild = true } return setmetatable(B, { __index = function(_, key) if LIVE[key] then local st = B.current() if not st then return nil end if key == "turnCount" then return st.turn end if key == "phase" then local M = battleModule() return M and M._phase end return st[key] end local why = absent[key] if why then warnOnce("battle." .. key, "[%s] BattleState.%s has no Gen 3 backing: %s", who("src.battle.BattleState"), key, why) end return nil end, }) end COVERAGE["src.battle.BattleState"] = { kind = "facade", target = "src.core.game3.battle", backed = "isActive current say sayAuto player enemy turnCount phase result " .. "kind wild", warned = "", absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn " .. "executeAction performMove computeDamage catchAttempt runRoll enter " .. "exit sgbPalettes awardExp throwBall storeCaughtMon tryRun update draw", notes = { player = "the live FireRed battler { mon, side, species, type1, type2, " .. "ability, stages, status }; mon carries NUMERIC species and moves", newWild = "ABSENT: start a wild battle with mod.world:startWildBattle, or " .. "rewrite the species through the encounter.species hook", say = "queues onto the FireRed battle textbox (battle/ui.lua Ui.push)", events = "battle.* events and hooks fire from src/core/game3/battle for every " .. "battler, with battlerId 0-3 in doubles; species, moves and items are " .. "NAME strings with the FireRed number alongside (speciesId, moveNum, ballId)", }, } -- ------- src.script.ScriptRunner local SR = "src.script.ScriptRunner" local function buildScriptRunner() local okGen1, Gen1 = pcall(rawRequire, "src.script.ScriptRunner") local adapter = {} adapter.scanLabels = okGen1 and Gen1.scanLabels or nil function adapter.validate(script, lookup) if not (okGen1 and Gen1.validate) then return nil end return Gen1.validate(script, lookup or function(verb) local g = live() local commands = g and g.data and g.data.commands return commands ~= nil and commands[verb] ~= nil end) end local Handle = {} Handle.__index = function(self, key) if key == "game" then return live() end local S = space() local vm = S and S.vm if key == "vm" then return vm end if key == "ctx" then return vm and vm.ctx end return rawget(Handle, key) end function Handle:isRunning() local S = space() return S and S.vm and S.vm:isRunning() or false end function Handle:run(script, extra) if extra ~= nil then warnOnce("runner.run.extra", "[%s] runner:run's `extra` is unserved on FireRed", who(SR)) end local S = space() if not (S and S.vm) then return false end if type(script) ~= "string" then warnOnce("runner.run.rows", "[%s] runner:run on FireRed takes an extracted script KEY (g3:...); a " .. "Gen 1 row list runs through mod.world:queueScript", who(SR)) return false end if S.vm:isRunning() then return false end return S.startScript(script) and true or false end Handle.resume = unbacked(SR, "runner:resume", "the FireRed VM is ticked by src/core/game3/field.lua; a second drive " .. "double-steps it") Handle.update = unbacked(SR, "runner:update", "the FireRed VM is ticked by src/core/game3/field.lua every frame") Handle.exec = unbacked(SR, "runner:exec", "the script.command hook is the supported patch point") Handle.yield = unbacked(SR, "runner:yield", "FireRed scripts are pret bytecode, not coroutines") Handle.makeContext = unbacked(SR, "runner:makeContext", "Gen3Compat.scriptCtx(vm) builds the Gen 1-shaped ctx events carry") function adapter.new(_game, _overworld) return setmetatable({}, Handle) end return adapter end COVERAGE[SR] = { kind = "facade", target = "src.core.game3.scripting.vm", backed = "scanLabels validate new isRunning run game vm ctx", warned = "resume update exec yield makeContext", absent = "co waitingFrames parallel waitingCheck overworld __index", notes = { run = "takes an extracted FireRed script key and starts it on the one " .. "Space VM; returns false while another script runs", new = "a thin handle onto src/core/game3/scripting/space.lua's VM, which " .. "is replaced on every map change, so the handle resolves it per call", }, } -- ------- src.world.WorldAPI COVERAGE["src.world.WorldAPI"] = { kind = "alias", target = "src.world.game3.WorldAPI", backed = "new __index overworld current activeBlockAt canReorderParty " .. "reorderParty availableFieldActions useFieldAction canFly flyTo " .. "mapOverview warpTo toggleObject setFlag getFlag effectiveEncounters " .. "replaceBlock npc queueScript startWildBattle invalidateMap", warned = "spawnNpc removeNpc", absent = "", notes = { current = "mapId is the raw FR_ id; warpTo accepts either spelling", setFlag = "a FireRed flag NAME (with or without FLAG_) or number", replaceBlock = "one 16px metatile, not a 32px block", queueScript = "the five Gen 2 verbs (start_battle wild, warp, text, " .. "setflag, clearflag); anything else is refused by name up front", }, } -- ------- sprites local spriteOverrides = { front = {}, back = {} } local imageCache = {} local wrappedModules = setmetatable({}, { __mode = "k" }) local reloadRegistered = setmetatable({}, { __mode = "k" }) local function isVanillaPic(path) return type(path) ~= "string" or path == "" or path:find("%.rgba$") ~= nil end local function readImageData(path) if not (love and love.image and love.image.newImageData) then return nil end local okA, Assets = pcall(rawRequire, "src.render.Assets") if okA and Assets and Assets.imageData then local ok, data = pcall(Assets.imageData, path) if ok and data then return data end end local ok, data = pcall(love.image.newImageData, path) if ok and data then return data end return nil end local function centredEntry(path) local hit = imageCache[path] if hit ~= nil then return hit or nil end local data = readImageData(path) if not (data and love.graphics and love.graphics.newImage) then warnOnce("sprite." .. tostring(path), "[gen3] sprite override %s could not be loaded", tostring(path)) imageCache[path] = false return nil end local w, h = data:getDimensions() local out = data if w ~= 64 or h ~= 64 then out = love.image.newImageData(64, 64) local cw, ch = math.min(w, 64), math.min(h, 64) local sx = math.floor((w - cw) / 2) local sy = math.floor((h - ch) / 2) local dx = math.floor((64 - cw) / 2) local dy = math.floor((64 - ch) / 2) out:paste(data, dx, dy, sx, sy, cw, ch) end local image = love.graphics.newImage(out) if image.setFilter then image:setFilter("nearest", "nearest") end hit = { image = image, w = 64, h = 64, path = path } imageCache[path] = hit return hit end Gen3Compat.centredSprite = centredEntry local function samePath(path) return path end local function hookedEntry(side, species, form, vanilla) if not Runtime.wantsHook("pokemon.sprite") then return vanilla end local P = g3("pokemon") local path = spriteOverrides[side][species] or ("data/generated/gba/pokemon/" .. side .. "/" .. species .. ".rgba") local g = live() local ctx = { data = g and dataProxy(g.data), species = Gen3Compat.speciesName(species), gen3Species = species, form = form, side = side, kind = "battle", trueColor = true, path = path } local hooked = Runtime.call("pokemon.sprite", samePath, path, ctx) if type(hooked) ~= "string" or hooked == path or isVanillaPic(hooked) then return vanilla end return centredEntry(hooked) or vanilla end local function wrapPics(P) if not P or wrappedModules[P] then return end wrappedModules[P] = true local frontOrig, backOrig = P.frontPic, P.backPic if frontOrig then P.frontPic = function(species, form, shiny, personality) local sp = tonumber(species) local path = sp and (tonumber(form) or 0) == 0 and spriteOverrides.front[sp] local entry = path and centredEntry(path) if not entry then entry = frontOrig(species, form, shiny, personality) end if sp then return hookedEntry("front", sp, form, entry) end return entry end P.frontSprite = P.frontPic end if backOrig then P.backPic = function(species, form, shiny) local sp = tonumber(species) local path = sp and (tonumber(form) or 0) == 0 and spriteOverrides.back[sp] local entry = path and centredEntry(path) if not entry then entry = backOrig(species, form, shiny) end if sp then return hookedEntry("back", sp, form, entry) end return entry end end end local function seed(P) if not P then return end P._front = P._front or {} P._back = P._back or {} for sp, path in pairs(spriteOverrides.front) do local entry = centredEntry(path) if entry then P._front[sp] = entry end end for sp, path in pairs(spriteOverrides.back) do local entry = centredEntry(path) if entry then P._back[sp] = entry end end end function Gen3Compat.reseedSprites() local P = package.loaded["src.core.game3.pokemon"] if P then wrapPics(P) seed(P) end end local function collectOverrides(game) spriteOverrides = { front = {}, back = {} } local content = game and game.mods and game.mods.content local reg = content and content.pokemon if not (reg and reg.ops) then return end for id in pairs(reg.ops) do local ok, rec = pcall(reg.get, reg, id) if ok and type(rec) == "table" then local sp = Gen3Compat.speciesId(id) if not sp and type(rec.gen3Species) == "number" then sp = rec.gen3Species end if sp then if not isVanillaPic(rec.spriteFront) then spriteOverrides.front[sp] = rec.spriteFront64 or rec.spriteFront end if not isVanillaPic(rec.spriteBack) then spriteOverrides.back[sp] = rec.spriteBack64 or rec.spriteBack end end end end end function Gen3Compat.spriteOverrides() local out = { front = {}, back = {} } for side, map in pairs(spriteOverrides) do for sp, path in pairs(map) do out[side][sp] = path end end return out end local function reapply(name, target, what) local g = live() local content = g and g.mods and g.mods.content local reg = content and content[name] if not (target and reg and reg.ops and next(reg.ops) ~= nil) then return end local spec = reg.spec if spec and type(spec.write) == "function" then local ok, err = pcall(spec.write, target, reg) if not ok then warnOnce(name .. ".reapply", "[gen3] mod %s data not re-applied after reload: %s", what, tostring(err)) end end for key in pairs(recordCache) do recordCache[key] = nil end end local function reapplyMoves(M) reapply("moves", M, "move") end -- Pokemon.install() replaces every species table with a fresh copy of the -- ROM pack, and Runtime runs it on entering FireRed -- after the loader has -- merged the mods onto the old tables. Write the moves and pokemon -- registries again onto the new ones: move names live in that same pack -- (Pokemon._moveNames), and so does the battle-move copy the moves writer -- mirrors its rows into. Moves first, as Loader:_mergeOrder does: the moves -- writer is what puts a mod's own moves in the move index, and the species -- writer reads that index to resolve learnsets, egg moves and TM/HM lists. local function reapplyPokemon(P) local g = live() local M = g and g.data and g.data.gen3Moves if not M then local okM, Moves = pcall(rawRequire, "src.core.game3.battle.moves") M = okM and type(Moves) == "table" and Moves or nil end reapplyMoves(M) reapply("pokemon", P, "species") end function Gen3Compat.applyMerged(game) if game then lastGame = game end imageCache = {} for key in pairs(recordCache) do recordCache[key] = nil end local okM, Moves = pcall(rawRequire, "src.core.game3.battle.moves") if okM and type(Moves) == "table" and type(Moves.onReload) == "function" and not reloadRegistered[Moves] then reloadRegistered[Moves] = true Moves.onReload(reapplyMoves, "gen3compat") end collectOverrides(game or live()) local okP, P = pcall(rawRequire, "src.core.game3.pokemon") if not (okP and P) then return end wrapPics(P) seed(P) if type(P.onReload) == "function" and not reloadRegistered[P] then reloadRegistered[P] = true P.onReload(function() for key in pairs(recordCache) do recordCache[key] = nil end reapplyPokemon(P) wrapPics(P) seed(P) end, "gen3compat") end end -- ------- script ctx function Gen3Compat.scriptCtx(vm) local S = space() return { game = live(), save = SAVE_VIEW, overworld = inField() and Gen3Compat.resolve(OW) or nil, runner = vm or (S and S.vm), vm = vm or (S and S.vm), generation = 3, } end -- ------- the table local ADAPTERS = { ["src.core.Game"] = buildGame, ["src.world.NPC"] = buildNpc, ["src.world.Collision"] = buildCollision, ["src.world.FieldDefaults"] = buildFieldDefaults, ["src.pokemon.Boxes"] = buildBoxes, ["src.world.OverworldController"] = buildOverworld, ["src.ui.PartyMenu"] = buildPartyMenu, ["src.ui.StartMenu"] = buildStartMenu, ["src.ui.OptionsMenu"] = buildOptionsMenu, ["src.battle.BattleState"] = buildBattleState, ["src.script.ScriptRunner"] = buildScriptRunner, ["src.world.PikachuFollower"] = buildFollower, ["src.world.Map"] = buildMap, ["src.world.WorldAPI"] = "src.world.game3.WorldAPI", ["src.ui.BoxMenu"] = buildBoxMenu, } Gen3Compat.ADAPTERS = ADAPTERS function Gen3Compat.endSession() resolveGame, lastGame = nil, nil built, claimants, warned = {}, {}, {} end function Gen3Compat.bind(fn) resolveGame = fn end function Gen3Compat.serves(name) return ADAPTERS[name] ~= nil end function Gen3Compat.modules() local out = {} for name in pairs(ADAPTERS) do out[#out + 1] = name end table.sort(out) return out end function Gen3Compat.coverage(name) local row = COVERAGE[name] if not row then return nil end local members = {} for _, status in ipairs({ "backed", "absent", "warned" }) do for _, member in ipairs(words(row[status])) do members[member] = status end end local notes = {} for key, value in pairs(row.notes or {}) do notes[key] = value end return { module = name, kind = row.kind, target = row.target, members = members, notes = notes } end function Gen3Compat.memberStatus(name, member) local row = Gen3Compat.coverage(name) return row and row.members[member] or nil end function Gen3Compat.resolve(name, modId) local spec = ADAPTERS[name] if not spec then return nil end local module = built[name] if not module then module = type(spec) == "string" and rawRequire(spec) or spec() built[name] = module end if modId then local ids = claimants[name] if not ids then ids = {} claimants[name] = ids end local seen = false for _, id in ipairs(ids) do if id == modId then seen = true break end end if not seen then ids[#ids + 1] = modId end end return module end return Gen3Compat