Files
gen1recomp/src/core/game3/pokemon.lua
T
Shane McGovern 58dfc00538 Extract FRLG gEggMoves so Gen 3 mods see egg moves
The Gen 3 extractor emitted eggCycles/eggGroups but never egg moves, so the
gen3_dexnav hidden-mon roll was inert on FireRed: a hidden mon kept its
level-up moves and could never gain an egg move.

Decode the ROM's own gEggMoves table (pokefirered/src/data/pokemon/
egg_moves.h) into pokemon/egg_moves.lua and expose it the same way as
learnsets/tmhm:

- Versions: EGG_MOVES = 0x25EF0C plus the 20000 species offset, the 0xFFFF
  run terminator and a defensive per-species cap.  The table is one flat u16
  stream of `{ species + 20000, move..., 0xFFFF }` runs with no final
  terminator, so the first word that is neither a header nor a plausible move
  id ends the scan.
- PokemonExtract: extract_egg_moves + write_egg_moves_lua, written to the
  cache by run(), required by ready() so a stale cache re-extracts, and
  returned as pack.eggMoves.  FORMAT_VERSION 3 -> 4.
- Pokemon.eggMoves(species): runtime accessor with the same species coercion
  as Pokemon.learnset; nil for a species with no egg move.
- Schemas: eggMoves on monTables/monRecord/writeMon and the Gen 3 species
  field list, so a mod reads record.eggMoves as move ids and writes names back.
- Versions.CACHE_VERSION 98 -> 99 to force re-extraction.

The table is sparse: species without egg moves are absent rather than an
empty list, all the way from the ROM to the mod record.

Validated against a supported FireRed USA 1.0 dump: 165 species, 973 moves,
<=8 per species, Bulbasaur {113,130,219,204,80,345,320,174}, Mankey
{157,193,96,68,179,251,279,265} (no Toxic), Mew nil.

Tests: new tests/engine/game3_egg_moves.lua (31 checks ROM-free, 39 with a
ROM), gate_gen3_mod_api 734/734, engine tier 587/587, modkit tier 37/37,
lint --gate clean.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-19 10:45:52 +01:00

1154 lines
38 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- Runtime FRLG species names / menu icons / types (extracted pack).
local Extract = require("src.import.gba.extract_island1")
local PokemonExtract = require("src.import.gba.pokemon_extract")
local Versions = require("src.import.gba.versions")
local ModRuntime = require("src.mods.Runtime")
local Pokemon = {}
Pokemon._cache = nil
Pokemon._names = nil
Pokemon._types = nil
Pokemon._national = nil
Pokemon._manifest = nil
Pokemon._byName = nil -- normalized host/FRLG name → internal SPECIES
Pokemon._icons = {} -- [species] = { image, w, h }
Pokemon._front = {} -- [species] = { image, w, h }
Pokemon._romBytes = nil -- cached full ROM string for lazy front-pic decode
Pokemon._stats = nil
Pokemon._abilities = nil
Pokemon._abilityNames = nil
Pokemon._speciesMeta = nil
Pokemon._moveNames = nil
Pokemon._learnsets = nil
Pokemon._eggMoves = nil
Pokemon._evolutions = nil
Pokemon._tmhm = nil
Pokemon._dex = nil
Pokemon._battleMoves = nil
Pokemon._logged = false
local ROOT = (Extract.CACHE_ROOT or "data/generated/gba") .. "/pokemon"
local function log(msg)
if Pokemon._logged then return end
Pokemon._logged = true
print("[game3/pokemon] " .. tostring(msg))
end
local function resolve_cache(cache)
if cache and cache.read then return cache end
local okD, Dataset = pcall(require, "src.core.game3.dataset")
if okD and Dataset and Dataset.cache then
local c = Dataset.cache()
if c then return c end
end
return {
read = function(_, rel)
local ok, CacheFs = pcall(require, "src.import.CacheFs")
if ok and CacheFs and CacheFs.readActive then
local data = CacheFs.readActive(rel)
if data then return data end
end
local f = io.open(rel, "rb") or io.open("data/generated/gba/" .. rel, "rb")
if f then
local data = f:read("*a")
f:close()
return data
end
return nil
end,
}
end
local function load_lua(cache, rel)
cache = resolve_cache(cache)
local src = cache:read(rel)
if not src then return nil end
local chunk = load(src, "@" .. rel, "t", {})
if not chunk then return nil end
local ok, t = pcall(chunk)
if ok then return t end
return nil
end
--- Normalize host id / FRLG display name for reverse lookup.
-- "KYOGRE" / "NIDORAN_F" / "FARFETCH'D" / "HO-OH" / "MR. MIME"
local function norm_key(s)
if s == nil then return nil end
s = tostring(s):upper()
-- Common host aliases before stripping.
s = s:gsub("NIDORAN_F", "NIDORANF"):gsub("NIDORAN_M", "NIDORANM")
s = s:gsub("MR_MIME", "MRMIME"):gsub("MIME_JR", "MIMEJR")
s = s:gsub("FARFETCH[_']?D", "FARFETCHD")
s = s:gsub("HO[_%-]?OH", "HOOH")
s = s:gsub("NIDORAN♀", "NIDORANF"):gsub("NIDORAN♂", "NIDORANM")
s = s:gsub("FARFETCH'D", "FARFETCHD")
s = s:gsub("MR%.%s*MIME", "MRMIME"):gsub("MR%.", "MR")
s = s:gsub("HO%-OH", "HOOH")
s = s:gsub("[^A-Z0-9]", "")
return s
end
local function build_name_index(names)
local by = {}
if type(names) ~= "table" then return by end
for id, name in pairs(names) do
if type(id) == "number" and type(name) == "string" and name ~= "" then
local k = norm_key(name)
if k and k ~= "" and not by[k] then
by[k] = id
end
-- Extra keys for gendered / punctuated FRLG glyphs.
if name:find("♀", 1, true) then
by["NIDORANF"] = by["NIDORANF"] or id
end
if name:find("♂", 1, true) then
by["NIDORANM"] = by["NIDORANM"] or id
end
end
end
return by
end
function Pokemon.install(cache)
Pokemon._cache = resolve_cache(cache)
Pokemon._names = nil
Pokemon._types = nil
Pokemon._national = nil
Pokemon._manifest = nil
Pokemon._byName = nil
Pokemon._stats = nil
Pokemon._abilities = nil
Pokemon._abilityNames = nil
Pokemon._speciesMeta = nil
Pokemon._moveNames = nil
Pokemon._learnsets = nil
Pokemon._eggMoves = nil
Pokemon._evolutions = nil
Pokemon._tmhm = nil
Pokemon._dex = nil
Pokemon._battleMoves = nil
Pokemon._icons = {}
Pokemon._front = {}
Pokemon._romBytes = nil
Pokemon._logged = false
local root = (Extract.CACHE_ROOT or "data/generated/gba") .. "/pokemon"
local c = Pokemon._cache
Pokemon._manifest = load_lua(c, root .. "/manifest.lua")
Pokemon._names = load_lua(c, root .. "/names.lua")
Pokemon._types = load_lua(c, root .. "/types.lua")
Pokemon._national = load_lua(c, root .. "/national.lua")
Pokemon._stats = load_lua(c, root .. "/stats.lua")
Pokemon._abilities = load_lua(c, root .. "/abilities.lua")
Pokemon._abilityNames = load_lua(c, root .. "/ability_names.lua")
Pokemon._speciesMeta = load_lua(c, root .. "/meta.lua")
Pokemon._moveNames = load_lua(c, root .. "/move_names.lua")
Pokemon._learnsets = load_lua(c, root .. "/learnsets.lua")
Pokemon._eggMoves = load_lua(c, root .. "/egg_moves.lua")
Pokemon._evolutions = load_lua(c, root .. "/evolutions.lua")
Pokemon._tmhm = load_lua(c, root .. "/tmhm.lua")
Pokemon._dex = load_lua(c, root .. "/dex.lua")
local battlePack = load_lua(c, root .. "/battle_moves.lua")
Pokemon._battleMoves = battlePack and battlePack.moves or nil
Pokemon._byName = build_name_index(Pokemon._names)
if Pokemon._names then
log("species pack ready (" .. tostring(Pokemon._manifest and Pokemon._manifest.numSpecies) .. ")")
else
log("species pack missing — re-import FireRed ROM")
end
Pokemon._runReloadHooks()
end
Pokemon._reloadHooks = {}
function Pokemon.onReload(fn, key)
if type(fn) ~= "function" then return function() end end
local hooks = Pokemon._reloadHooks
for i = #hooks, 1, -1 do
local h = hooks[i]
if h.fn == fn or (key ~= nil and h.key == key) then
table.remove(hooks, i)
end
end
local entry = { fn = fn, key = key }
hooks[#hooks + 1] = entry
return function()
for i = #hooks, 1, -1 do
if hooks[i] == entry then table.remove(hooks, i) end
end
end
end
function Pokemon._runReloadHooks()
local snapshot = {}
for i, h in ipairs(Pokemon._reloadHooks) do snapshot[i] = h end
for _, h in ipairs(snapshot) do
local ok, err = pcall(h.fn, Pokemon)
if not ok then log("onReload callback failed: " .. tostring(err)) end
end
end
function Pokemon.invalidate()
Pokemon._icons = {}
Pokemon._front = {}
Pokemon._back = nil
Pokemon._romBytes = nil
Pokemon._names = nil
Pokemon._types = nil
Pokemon._national = nil
Pokemon._manifest = nil
Pokemon._byName = nil
Pokemon._stats = nil
Pokemon._abilities = nil
Pokemon._abilityNames = nil
Pokemon._speciesMeta = nil
Pokemon._moveNames = nil
Pokemon._learnsets = nil
Pokemon._eggMoves = nil
Pokemon._evolutions = nil
Pokemon._tmhm = nil
Pokemon._dex = nil
Pokemon._battleMoves = nil
Pokemon._logged = false
end
function Pokemon.ready()
if Pokemon._names then return true end
local cache = Pokemon._cache
return PokemonExtract.ready(cache, Extract.CACHE_ROOT)
or load_lua(cache, ROOT .. "/names.lua") ~= nil
end
--- Internal FRLG SPECIES id → display name.
function Pokemon.name(species)
species = tonumber(species)
if not species or species < 1 then return "?????" end
if not Pokemon._names then Pokemon.install(Pokemon._cache) end
local n = Pokemon._names and Pokemon._names[species]
if n and n ~= "" and n ~= "??????????" then return n end
return string.format("POKéMON %03d", species)
end
function Pokemon.keyName(species)
species = tonumber(species)
if not species or species < 1 then return nil end
if not Pokemon._names then Pokemon.install(Pokemon._cache) end
local n = Pokemon._names and Pokemon._names[species]
if type(n) ~= "string" or n == "" or n == "??????????" then return nil end
n = n:upper()
n = n:gsub("♀", "_F"):gsub("♂", "_M")
n = n:gsub("[%.']", "")
n = n:gsub("[%s%-]+", "_")
return n
end
function Pokemon.speciesFromName(name)
if name == nil then return nil end
if not Pokemon._byName then Pokemon.install(Pokemon._cache) end
local k = norm_key(name)
if not k then return nil end
return Pokemon._byName and Pokemon._byName[k]
end
--- National dex number → internal SPECIES (when pack present).
function Pokemon.speciesFromNational(nat)
nat = tonumber(nat)
if not nat or nat < 1 then return nil end
if not Pokemon._national then Pokemon.install(Pokemon._cache) end
local t = Pokemon._national
if t and t.toSpecies and t.toSpecies[nat] then
return t.toSpecies[nat]
end
if nat <= 251 then return nat end
return nil
end
function Pokemon.national(species)
species = tonumber(species)
if not species or species < 1 then return nil end
if not Pokemon._national then Pokemon.install(Pokemon._cache) end
local t = Pokemon._national
if t and t.toNational then return t.toNational[species] end
if species <= 251 then return species end
return nil
end
function Pokemon.types(species)
species = tonumber(species)
if not species then return { 0, 0 } end
if not Pokemon._types then Pokemon.install(Pokemon._cache) end
local t = Pokemon._types and Pokemon._types[species]
if t then return { t[1] or 0, t[2] or 0 } end
return { 0, 0 }
end
--- Base stats table { hp, atk, def, spe, spa, spd } or nil.
function Pokemon.stats(species)
species = tonumber(species)
if not species then return nil end
if not Pokemon._stats then Pokemon.install(Pokemon._cache) end
return Pokemon._stats and Pokemon._stats[species]
end
--- Ability ids { ability1, ability2 }.
function Pokemon.abilities(species)
species = tonumber(species)
if not species then return { 0, 0 } end
if not Pokemon._abilities then Pokemon.install(Pokemon._cache) end
local a = Pokemon._abilities and Pokemon._abilities[species]
if a then return { a[1] or 0, a[2] or 0 } end
return { 0, 0 }
end
function Pokemon.abilityName(abilityId)
abilityId = tonumber(abilityId)
if not abilityId or abilityId < 1 then return "-------" end
if not Pokemon._abilityNames then Pokemon.install(Pokemon._cache) end
local n = Pokemon._abilityNames and Pokemon._abilityNames[abilityId]
if n and n ~= "" then return n end
return string.format("ABILITY %d", abilityId)
end
function Pokemon.speciesMeta(species)
species = tonumber(species)
if not species then return nil end
if not Pokemon._speciesMeta then Pokemon.install(Pokemon._cache) end
return Pokemon._speciesMeta and Pokemon._speciesMeta[species]
end
--- ROM BaseStats.expYield (species meta from extract).
function Pokemon.expYield(species)
local meta = Pokemon.speciesMeta(species)
return (meta and tonumber(meta.expYield)) or 0
end
--- ROM BaseStats.growthRate — pret GROWTH_* index into gExperienceTables.
function Pokemon.growthRate(species)
local meta = Pokemon.speciesMeta(species)
return (meta and tonumber(meta.growthRate) or 0) % 6
end
-- Gen3 genderRatio specials (match pret constants/pokemon.h).
Pokemon.GENDER_MALE = 0x00
Pokemon.GENDER_FEMALE = 0xFE
Pokemon.GENDER_GENDERLESS = 0xFF
-- Nature → { atk, def, spe, spa, spd } deltas (+1 / −1 / 0).
local NATURE_DELTAS = {
[0] = { 0, 0, 0, 0, 0 }, -- Hardy
[1] = { 1, -1, 0, 0, 0 }, -- Lonely
[2] = { 1, 0, -1, 0, 0 }, -- Brave
[3] = { 1, 0, 0, -1, 0 }, -- Adamant
[4] = { 1, 0, 0, 0, -1 }, -- Naughty
[5] = { -1, 1, 0, 0, 0 }, -- Bold
[6] = { 0, 0, 0, 0, 0 }, -- Docile
[7] = { 0, 1, -1, 0, 0 }, -- Relaxed
[8] = { 0, 1, 0, -1, 0 }, -- Impish
[9] = { 0, 1, 0, 0, -1 }, -- Lax
[10] = { -1, 0, 1, 0, 0 }, -- Timid
[11] = { 0, -1, 1, 0, 0 }, -- Hasty
[12] = { 0, 0, 0, 0, 0 }, -- Serious
[13] = { 0, 0, 1, -1, 0 }, -- Jolly
[14] = { 0, 0, 1, 0, -1 }, -- Naive
[15] = { -1, 0, 0, 1, 0 }, -- Modest
[16] = { 0, -1, 0, 1, 0 }, -- Mild
[17] = { 0, 0, -1, 1, 0 }, -- Quiet
[18] = { 0, 0, 0, 0, 0 }, -- Bashful
[19] = { 0, 0, 0, 1, -1 }, -- Rash
[20] = { -1, 0, 0, 0, 1 }, -- Calm
[21] = { 0, -1, 0, 0, 1 }, -- Gentle
[22] = { 0, 0, -1, 0, 1 }, -- Sassy
[23] = { 0, 0, 0, -1, 1 }, -- Careful
[24] = { 0, 0, 0, 0, 0 }, -- Quirky
}
function Pokemon.natureId(personality)
return (tonumber(personality) or 0) % 25
end
function Pokemon.gender(species, personality)
species = tonumber(species)
personality = tonumber(personality) or 0
local meta = Pokemon.speciesMeta(species)
local ratio = meta and meta.genderRatio
if ratio == nil then return "U" end
if ratio == Pokemon.GENDER_MALE then return "M" end
if ratio == Pokemon.GENDER_FEMALE then return "F" end
if ratio == Pokemon.GENDER_GENDERLESS then return "U" end
if ratio > (personality % 256) then return "F" end
return "M"
end
--- Ability id for species + personality (ability1 vs ability2).
function Pokemon.abilityId(species, personality)
species = tonumber(species)
personality = tonumber(personality) or 0
local pair = Pokemon.abilities(species)
local a1, a2 = pair[1] or 0, pair[2] or 0
if a2 ~= 0 and (personality % 2) == 1 then
return a2
end
return a1
end
local function nature_mul(nature, statIndex)
-- statIndex: 1=atk 2=def 3=spe 4=spa 5=spd
local d = NATURE_DELTAS[nature % 25]
if not d then return 1 end
local delta = d[statIndex] or 0
if delta > 0 then return 1.1 end
if delta < 0 then return 0.9 end
return 1
end
--- Gen3 CalculateMonStats → { maxHp, attack, defense, speed, spAtk, spDef }.
function Pokemon.calcStats(species, level, ivs, evs, personality)
species = tonumber(species)
level = tonumber(level) or 1
ivs = ivs or {}
evs = evs or {}
local base = Pokemon.stats(species)
if not base then
return {
maxHp = 15 + level * 2,
attack = 10, defense = 10, speed = 10, spAtk = 10, spDef = 10,
}
end
local nature = Pokemon.natureId(personality)
local function iv(k) return tonumber(ivs[k]) or 0 end
local function ev(k) return tonumber(evs[k]) or 0 end
local maxHp
if species == 292 then -- Shedinja
maxHp = 1
else
maxHp = math.floor(((2 * base.hp + iv("hp") + math.floor(ev("hp") / 4)) * level) / 100)
+ level + 10
end
local function other(baseStat, ivKey, evKey, natureIdx)
local n = math.floor(((2 * baseStat + iv(ivKey) + math.floor(ev(evKey) / 4)) * level) / 100) + 5
return math.floor(n * nature_mul(nature, natureIdx))
end
return {
maxHp = maxHp,
attack = other(base.atk, "atk", "atk", 1),
defense = other(base.def, "def", "def", 2),
speed = other(base.spe, "spe", "spe", 3),
spAtk = other(base.spa, "spa", "spa", 4),
spDef = other(base.spd, "spd", "spd", 5),
}
end
--- Fill battle/display stats on an opaque mon (mutates and returns mon).
function Pokemon.applyStats(mon)
if type(mon) ~= "table" then return mon end
local species = tonumber(mon.species or mon.speciesId) or 1
local level = tonumber(mon.level) or 5
local ivs = mon.ivs or {}
local evs = mon.evs or {}
local personality = mon.personality or 0
local st = Pokemon.calcStats(species, level, ivs, evs, personality)
mon.maxHp = st.maxHp
if mon.hp == nil or mon.hp < 0 or mon.hp > st.maxHp then
mon.hp = st.maxHp
end
mon.attack = st.attack
mon.defense = st.defense
mon.speed = st.speed
mon.spAtk = st.spAtk
mon.spDef = st.spDef
-- Aliases used by some battle paths.
mon.atk = st.attack
mon.def = st.defense
mon.spe = st.speed
mon.spa = st.spAtk
mon.spd = st.spDef
return mon
end
function Pokemon.moveName(moveId)
if type(moveId) == "table" then
moveId = moveId.id or moveId.move or moveId.moveId or moveId.num or moveId.name or moveId[1]
end
local num = tonumber(moveId)
if not num and type(moveId) == "string" then
local Moves = package.loaded["src.core.game3.battle.moves"]
if Moves and Moves.numForName then
num = Moves.numForName(moveId)
end
if not num then
if moveId ~= "" and moveId ~= "-------" then
return tostring(moveId):gsub("_", " "):upper()
end
end
end
if not num or num < 1 then return "-------" end
if not Pokemon._moveNames then Pokemon.install(Pokemon._cache) end
local n = Pokemon._moveNames and Pokemon._moveNames[num]
if n and n ~= "" then return n end
return string.format("MOVE %d", num)
end
function Pokemon.learnset(species)
if type(species) == "table" then species = Pokemon.speciesOf(species) end
if type(species) == "string" then species = Pokemon.speciesFromName(species) or tonumber(species) end
species = tonumber(species)
if not species then return {} end
if not Pokemon._learnsets then Pokemon.install(Pokemon._cache) end
return (Pokemon._learnsets and Pokemon._learnsets[species]) or {}
end
--- Egg move ids for a species (FRLG gEggMoves), or nil when it has none.
--- Mirrors Pokemon.learnset's species coercion so mods can pass either form.
function Pokemon.eggMoves(species)
if type(species) == "table" then species = Pokemon.speciesOf(species) end
if type(species) == "string" then species = Pokemon.speciesFromName(species) or tonumber(species) end
species = tonumber(species)
if not species then return nil end
if not Pokemon._eggMoves then Pokemon.install(Pokemon._cache) end
local list = Pokemon._eggMoves and Pokemon._eggMoves[species]
if type(list) ~= "table" or #list == 0 then return nil end
return list
end
function Pokemon.evolutions(species)
if type(species) == "table" then species = Pokemon.speciesOf(species) end
if type(species) == "string" then species = Pokemon.speciesFromName(species) or tonumber(species) end
species = tonumber(species)
if not species then return {} end
if not Pokemon._evolutions then Pokemon.install(Pokemon._cache) end
return (Pokemon._evolutions and Pokemon._evolutions[species]) or {}
end
function Pokemon.dexEntry(species)
if type(species) == "table" then species = Pokemon.speciesOf(species) end
if type(species) == "string" then species = Pokemon.speciesFromName(species) or tonumber(species) end
species = tonumber(species)
if not species then return nil end
if not Pokemon._dex then Pokemon.install(Pokemon._cache) end
local nat = Pokemon.national(species)
if not nat or not Pokemon._dex then return nil end
return Pokemon._dex[nat]
end
function Pokemon.battleMove(moveId)
moveId = tonumber(moveId)
if not moveId then return nil end
if not Pokemon._battleMoves then Pokemon.install(Pokemon._cache) end
return Pokemon._battleMoves and Pokemon._battleMoves[moveId]
end
function Pokemon.movePp(moveId)
moveId = tonumber(moveId)
if not moveId or moveId < 1 then return 5 end
local row = Pokemon.battleMove(moveId)
return (row and tonumber(row.pp)) or 5
end
Pokemon.moveMaxPp = Pokemon.movePp
--- FRLG GiveBoxMonInitialMoveset: learn all ≤ level; if full, drop first.
function Pokemon.movesAtLevel(species, level)
if type(species) == "table" then species = Pokemon.speciesOf(species) end
if type(species) == "string" then species = Pokemon.speciesFromName(species) or tonumber(species) end
species = tonumber(species)
level = tonumber(level) or 1
local set = Pokemon.learnset(species)
local pool = {}
for _, e in ipairs(set) do
local lv = e[1] or e.level or 0
local mv = tonumber(e[2] or e.move) or 0
if lv <= level and mv > 0 then
pool[#pool + 1] = mv
end
end
local moves = {}
local pp = {}
local maxPp = {}
for _, m in ipairs(pool) do
if #moves < 4 then
moves[#moves + 1] = m
local mpp = Pokemon.movePp(m)
pp[#pp + 1] = mpp
maxPp[#maxPp + 1] = mpp
else
table.remove(moves, 1)
table.remove(pp, 1)
table.remove(maxPp, 1)
moves[4] = m
local mpp = Pokemon.movePp(m)
pp[4] = mpp
maxPp[4] = mpp
end
end
return moves, pp, maxPp
end
--- Moves learned at exactly `level` (ROM learnset). Order preserved.
function Pokemon.movesLearnedAt(species, level)
if type(species) == "table" then species = Pokemon.speciesOf(species) end
if type(species) == "string" then species = Pokemon.speciesFromName(species) or tonumber(species) end
species = tonumber(species)
level = tonumber(level) or 0
local out = {}
if not species or level < 1 then return out end
for _, e in ipairs(Pokemon.learnset(species)) do
local lv = e[1] or e.level or 0
local mv = tonumber(e[2] or e.move) or 0
if lv == level and mv > 0 then
out[#out + 1] = mv
elseif lv > level then
break
end
end
return out
end
function Pokemon.moveIdAt(mon, slot)
if not mon or not mon.moves then return nil end
local entry = mon.moves[slot]
if type(entry) == "table" then return tonumber(entry.id or entry.move) end
return tonumber(entry)
end
function Pokemon.knowsMove(mon, moveId)
moveId = tonumber(moveId)
if not mon or not moveId then return false end
for i = 1, 4 do
if Pokemon.moveIdAt(mon, i) == moveId then return true end
end
return false
end
function Pokemon.moveSlotCount(mon)
local n = 0
for i = 1, 4 do
local id = Pokemon.moveIdAt(mon, i)
if id and id > 0 then n = n + 1 end
end
return n
end
-- FRLG HM move IDs (cannot forget).
local HM_MOVES = {
[15] = true, -- CUT
[19] = true, -- FLY
[57] = true, -- SURF
[70] = true, -- STRENGTH
[148] = true, -- FLASH
[249] = true, -- ROCK SMASH
[250] = true, -- WHIRLPOOL (Gen2 leftover; still protected in some builds)
[127] = true, -- WATERFALL
[291] = true, -- DIVE
}
function Pokemon.isHmMove(moveId)
return HM_MOVES[tonumber(moveId) or 0] == true
end
--- Item id 289 (TM01) … 346 (HM08) → battle move id via extracted sTMHMMoves.
function Pokemon.moveFromTmItem(itemId)
local num = tonumber(itemId)
if not num then
local ItemsData = require("src.core.game3.items_data")
num = ItemsData.toNumericId(itemId)
end
if not num or num < 289 or num > 346 then return nil end
if not Pokemon._tmhm then Pokemon.install(Pokemon._cache) end
local machines = Pokemon._tmhm and Pokemon._tmhm.machines
if not machines then return nil end
local tmIndex = num - 289 -- 0-based TM01
return tonumber(machines[tmIndex])
end
--- Can this species learn TM/HM machine index (0..57)?
function Pokemon.canLearnTmIndex(species, tmIndex)
species = tonumber(species)
tmIndex = tonumber(tmIndex)
if not species or not tmIndex or tmIndex < 0 or tmIndex > 57 then return false end
if not Pokemon._tmhm then Pokemon.install(Pokemon._cache) end
local row = Pokemon._tmhm and Pokemon._tmhm.learnsets and Pokemon._tmhm.learnsets[species]
if not row then return false end
local lo = tonumber(row.lo) or 0
local hi = tonumber(row.hi) or 0
if tmIndex < 32 then
return math.floor(lo / (2 ^ tmIndex)) % 2 == 1
end
return math.floor(hi / (2 ^ (tmIndex - 32))) % 2 == 1
end
function Pokemon.canLearnTmItem(species, itemId)
local num = tonumber(itemId)
if not num then
local ItemsData = require("src.core.game3.items_data")
num = ItemsData.toNumericId(itemId)
end
if not num or num < 289 or num > 346 then return false end
return Pokemon.canLearnTmIndex(species, num - 289)
end
local function move_max_pp(moveId)
local row = Pokemon.battleMove(moveId)
return (row and tonumber(row.pp)) or 5
end
--- Teach move into first empty slot. Returns true if taught.
function Pokemon.teachMove(mon, moveId)
moveId = tonumber(moveId)
if not mon or not moveId or moveId < 1 then return false end
if Pokemon.knowsMove(mon, moveId) then return false end
mon.moves = mon.moves or {}
mon.pp = mon.pp or {}
mon.maxPp = mon.maxPp or {}
for i = 1, 4 do
local id = Pokemon.moveIdAt(mon, i)
if not id or id == 0 then
mon.moves[i] = moveId
local max = move_max_pp(moveId)
mon.pp[i] = max
mon.maxPp[i] = max
-- pokefirered/src/pokemon.c:2208
if ModRuntime.wants("pokemon.move_learned") then
ModRuntime.emit("pokemon.move_learned", {
mon = mon, moveId = require("src.mods.Gen3Compat").moveName(moveId),
moveNum = moveId, slot = i,
})
end
return true, i
end
end
return false
end
--- Replace move at 1-based slot. Returns forgotten move id.
function Pokemon.replaceMove(mon, slot, newMoveId)
slot = tonumber(slot)
newMoveId = tonumber(newMoveId)
if not mon or not slot or slot < 1 or slot > 4 or not newMoveId then return nil end
local old = Pokemon.moveIdAt(mon, slot)
if Pokemon.isHmMove(old) then return nil, "hm" end
mon.moves = mon.moves or {}
mon.pp = mon.pp or {}
mon.maxPp = mon.maxPp or {}
mon.moves[slot] = newMoveId
local max = move_max_pp(newMoveId)
mon.pp[slot] = max
mon.maxPp[slot] = max
-- pokefirered/src/pokemon.c:2248
if ModRuntime.wants("pokemon.move_learned") then
local G3 = require("src.mods.Gen3Compat")
ModRuntime.emit("pokemon.move_learned", {
mon = mon, moveId = G3.moveName(newMoveId), moveNum = newMoveId, slot = slot,
forgotten = G3.moveName(old), forgottenNum = tonumber(old),
})
end
return old
end
function Pokemon.displayMonName(mon)
if not mon then return "POKéMON" end
local nick = mon.nickname
if type(nick) == "string" and nick ~= "" then return nick end
if mon.name and mon.name ~= "" then return mon.name end
local sp = Pokemon.speciesOf(mon)
return (sp and Pokemon.name(sp)) or "POKéMON"
end
--- Atomically swap two move slots on a Pokémon, keeping move ID, PP, and PP bonuses in sync.
--- Solves the PP Swap Trap.
function Pokemon.swapMoves(mon, slotA, slotB)
if not mon or not slotA or not slotB then return false end
slotA = tonumber(slotA)
slotB = tonumber(slotB)
if not slotA or not slotB or slotA < 1 or slotA > 4 or slotB < 1 or slotB > 4 then
return false
end
if slotA == slotB then return true end
-- 1. If mon.moves is an array of tables: { id = ..., pp = ..., ppBonuses = ... }
if type(mon.moves) == "table" then
local entryA = mon.moves[slotA]
local entryB = mon.moves[slotB]
mon.moves[slotA] = entryB
mon.moves[slotB] = entryA
end
-- 2. If parallel array mon.moveIds exists
if type(mon.moveIds) == "table" then
local idA = mon.moveIds[slotA]
mon.moveIds[slotA] = mon.moveIds[slotB]
mon.moveIds[slotB] = idA
end
-- 3. If parallel array mon.pp exists
if type(mon.pp) == "table" then
local ppA = mon.pp[slotA]
mon.pp[slotA] = mon.pp[slotB]
mon.pp[slotB] = ppA
end
-- 4. If parallel array mon.ppBonuses / mon.ppBonus / mon.ppUp exists
if type(mon.ppBonuses) == "table" then
local bA = mon.ppBonuses[slotA]
mon.ppBonuses[slotA] = mon.ppBonuses[slotB]
mon.ppBonuses[slotB] = bA
end
if type(mon.ppBonus) == "table" then
local bA = mon.ppBonus[slotA]
mon.ppBonus[slotA] = mon.ppBonus[slotB]
mon.ppBonus[slotB] = bA
end
if type(mon.ppUp) == "table" then
local bA = mon.ppUp[slotA]
mon.ppUp[slotA] = mon.ppUp[slotB]
mon.ppUp[slotB] = bA
end
-- 5. If PP bonuses are stored as packed bits (Gen 3 BoxMon / Pokemon: 2 bits per slot)
if type(mon.ppBonusesPacked) == "number" then
local packed = mon.ppBonusesPacked
local shiftA = (slotA - 1) * 2
local shiftB = (slotB - 1) * 2
local bonusA = bit.band(bit.rshift(packed, shiftA), 3)
local bonusB = bit.band(bit.rshift(packed, shiftB), 3)
packed = bit.band(packed, bit.bnot(bit.bor(bit.lshift(3, shiftA), bit.lshift(3, shiftB))))
packed = bit.bor(packed, bit.lshift(bonusA, shiftB), bit.lshift(bonusB, shiftA))
mon.ppBonusesPacked = packed
end
-- 6. If an active overlay exists on the mon (e.g. party menu overlay)
if type(mon.overlay) == "table" then
local ovA = mon.overlay[slotA]
mon.overlay[slotA] = mon.overlay[slotB]
mon.overlay[slotB] = ovA
end
return true
end
function Pokemon.isEgg(mon)
if not mon then return false end
return (mon.isEgg == true) or (mon.egg == true) or (mon.species == 412)
end
local function read_rgba(species)
local cache = resolve_cache(Pokemon._cache)
local root = (Extract.CACHE_ROOT or "data/generated/gba") .. "/pokemon"
local rel = root .. "/icons/" .. species .. ".rgba"
local d = cache:read(rel)
if type(d) == "string" and #d > 0 then return d end
return nil
end
local function image_from_rgba(rgba, w, h)
if not (love and love.image and love.graphics) then return nil end
if not rgba or #rgba < w * h * 4 then return nil end
local ok, imageData = pcall(love.image.newImageData, w, h, "rgba8", rgba)
if not ok or not imageData then
imageData = love.image.newImageData(w, h)
local i = 1
for y = 0, h - 1 do
for x = 0, w - 1 do
imageData:setPixel(x, y,
(rgba:byte(i) or 0) / 255,
(rgba:byte(i + 1) or 0) / 255,
(rgba:byte(i + 2) or 0) / 255,
(rgba:byte(i + 3) or 0) / 255)
i = i + 4
end
end
end
local image = love.graphics.newImage(imageData)
if image.setFilter then image:setFilter("nearest", "nearest") end
return image
end
--- Love Image for menu icon, or nil.
function Pokemon.icon(species)
species = tonumber(species)
if not species or species < 1 then return nil end
if Pokemon._icons[species] then return Pokemon._icons[species] end
local w = (Pokemon._manifest and Pokemon._manifest.iconW) or Versions.MON_ICON_W or 32
local h = (Pokemon._manifest and Pokemon._manifest.iconH) or Versions.MON_ICON_H or 32
local rgba = read_rgba(species)
if not rgba then return nil end
local actualH = (#rgba >= w * (h * 2) * 4) and (h * 2) or h
local image = image_from_rgba(rgba, w, actualH)
if not image then return nil end
local frames = math.max(1, math.floor(actualH / h))
local quads = {}
for f = 0, frames - 1 do
quads[f] = love.graphics.newQuad(0, f * h, w, h, w, actualH)
end
local entry = { image = image, w = w, h = h, sheetH = actualH, frames = frames, quads = quads }
Pokemon._icons[species] = entry
return entry
end
local function bgr555_to_rgb8(c)
c = (tonumber(c) or 0) % 32768
local r5 = c % 32
local g5 = math.floor(c / 32) % 32
local b5 = math.floor(c / 1024) % 32
return math.floor(r5 * 255 / 31 + 0.5),
math.floor(g5 * 255 / 31 + 0.5),
math.floor(b5 * 255 / 31 + 0.5)
end
local function load_rom_bytes()
if Pokemon._romBytes then return Pokemon._romBytes end
local candidates = {
"1636 - Pokemon Fire Red (U)(Squirrels).gba",
"firered.gba",
"Pokemon FireRed.gba",
}
for _, path in ipairs(candidates) do
local f = io.open(path, "rb")
if f then
local data = f:read("*a")
f:close()
if data and #data >= 0x1000000 then
Pokemon._romBytes = data
return data
end
end
if love and love.filesystem and love.filesystem.read then
local ok, data = pcall(love.filesystem.read, path)
if ok and data and #data >= 0x1000000 then
Pokemon._romBytes = data
return data
end
end
end
local okC, CacheFs = pcall(require, "src.import.CacheFs")
if okC and CacheFs and CacheFs.readActive then
for _, path in ipairs(candidates) do
local data = CacheFs.readActive(path)
if data and #data >= 0x1000000 then
Pokemon._romBytes = data
return data
end
end
end
return nil
end
local function rom_u8(data, off)
return data:byte(off + 1) or 0
end
local function rom_u16(data, off)
return rom_u8(data, off) + rom_u8(data, off + 1) * 256
end
local function rom_u32(data, off)
return rom_u8(data, off)
+ rom_u8(data, off + 1) * 256
+ rom_u8(data, off + 2) * 65536
+ rom_u8(data, off + 3) * 16777216
end
--- Linear 4bpp decode helper shared by front/back.
local function decode_pic_rgba(species, picTable, palTable, cacheRel, form, fileOffs)
species = tonumber(species)
if not species or species < 1 then return nil end
local cache = resolve_cache(Pokemon._cache)
if cache and cache.read then
local d = cache:read(cacheRel)
if d and #d >= 64 * 64 * 4 then return d end
end
local data = load_rom_bytes()
if not data then return nil end
local Lz77 = require("src.import.gba.lz77")
local tileFile, palFile
if fileOffs then
tileFile, palFile = fileOffs[1], fileOffs[2]
else
tileFile = Versions.gbaToFile(rom_u32(data, picTable + species * 8))
palFile = Versions.gbaToFile(rom_u32(data, palTable + species * 8))
end
if not tileFile or not palFile then return nil end
form = tonumber(form) or 0
local function get(i)
return rom_u8(data, i)
end
local okT, tiles = pcall(Lz77.decompress, get, tileFile)
local okP, palBytes = pcall(Lz77.decompress, get, palFile)
if not okT or not okP or type(tiles) ~= "table" or type(palBytes) ~= "table" then
return nil
end
local pal = {}
for c = 0, 15 do
local lo = palBytes[form * 32 + c * 2 + 1] or 0
local hi = palBytes[form * 32 + c * 2 + 2] or 0
pal[c] = lo + hi * 256
end
local w, h = 64, 64
local rgb = {}
for c = 0, 15 do
local r, g, b = bgr555_to_rgb8(pal[c] or 0)
rgb[c] = { r, g, b }
end
local tilesW, tilesH = 8, 8
local chunks = {}
local ti = 0
for ty = 0, tilesH - 1 do
for tx = 0, tilesW - 1 do
local tileOff = form * 2048 + ti * 32
for row = 0, 7 do
for bx = 0, 3 do
local bi = tileOff + row * 4 + bx + 1
local byte = tiles[bi] or 0
local p0 = byte % 16
local p1 = math.floor(byte / 16) % 16
local x0 = tx * 8 + bx * 2
local y0 = ty * 8 + row
local function put(x, y, idx)
local i = y * w + x + 1
if idx == 0 then
chunks[i] = string.char(0, 0, 0, 0)
else
local c = rgb[idx] or rgb[0]
chunks[i] = string.char(c[1], c[2], c[3], 255)
end
end
put(x0, y0, p0)
put(x0 + 1, y0, p1)
end
end
ti = ti + 1
end
end
local rgba = table.concat(chunks)
if cache and cache.write then
pcall(cache.write, cache, cacheRel, rgba)
end
return rgba
end
local SPECIES_CASTFORM = 385
local function form_of(species, form)
form = tonumber(form) or 0
if species ~= SPECIES_CASTFORM or form < 1 or form > 3 then return 0 end
return form
end
local function pic_rel(kind, species, form)
local root = (Extract.CACHE_ROOT or "data/generated/gba") .. "/pokemon/" .. kind .. "/"
if form > 0 then return root .. species .. "_" .. form .. ".rgba" end
return root .. species .. ".rgba"
end
local function decode_front_rgba(species, form)
species = tonumber(species)
if not species or species < 1 then return nil end
local picTable = (Versions.OAK_SPEECH and Versions.OAK_SPEECH.mon_front_pic_table) or 0x2350AC
local palTable = (Versions.OAK_SPEECH and Versions.OAK_SPEECH.mon_palette_table) or 0x23730C
return decode_pic_rgba(species, picTable, palTable, pic_rel("front", species, form), form)
end
local function decode_back_rgba(species, form)
species = tonumber(species)
if not species or species < 1 then return nil end
local picTable = Versions.MON_BACK_PIC_TABLE or 0x23654C
local palTable = (Versions.OAK_SPEECH and Versions.OAK_SPEECH.mon_palette_table) or 0x23730C
return decode_pic_rgba(species, picTable, palTable, pic_rel("back", species, form), form)
end
local function pic_entry(store, key, rgba)
local image = image_from_rgba(rgba, 64, 64)
if not image then return nil end
local entry = { image = image, w = 64, h = 64 }
store[key] = entry
return entry
end
--- 64×64 front pic for showmonpic (ROM-lazy or cache), else nil.
-- pokefirered/src/battle_gfx_sfx_util.c:354
function Pokemon.frontPic(species, form)
species = tonumber(species)
if not species or species < 1 then return nil end
form = form_of(species, form)
local key = form > 0 and (species .. "_" .. form) or species
if Pokemon._front[key] then return Pokemon._front[key] end
local entry = pic_entry(Pokemon._front, key, decode_front_rgba(species, form))
if not entry and form > 0 then return Pokemon.frontPic(species) end
return entry
end
Pokemon.frontSprite = Pokemon.frontPic
--- 64×64 back pic for battle (ROM-lazy or cache).
function Pokemon.backPic(species, form)
species = tonumber(species)
if not species or species < 1 then return nil end
Pokemon._back = Pokemon._back or {}
form = form_of(species, form)
local key = form > 0 and (species .. "_" .. form) or species
if Pokemon._back[key] then return Pokemon._back[key] end
local entry = pic_entry(Pokemon._back, key, decode_back_rgba(species, form))
if not entry and form > 0 then return Pokemon.backPic(species) end
return entry
end
-- pokefirered/src/battle_gfx_sfx_util.c:422
function Pokemon.ghostPic()
if Pokemon._front.ghost then return Pokemon._front.ghost end
local offs = Versions.GHOST_FRONT_PIC and Versions.GHOST_PALETTE
and { Versions.GHOST_FRONT_PIC, Versions.GHOST_PALETTE } or nil
local rgba = decode_pic_rgba(1, 0, 0, pic_rel("front", "ghost", 0), 0, offs or { false, false })
return pic_entry(Pokemon._front, "ghost", rgba)
end
--- Resolve display species for a host/opaque mon table.
-- Host mons use string ids ("KYOGRE"); FRLG scripts use internal SPECIES ints.
function Pokemon.speciesOf(mon)
if not mon then return nil end
local raw = mon.species or mon.speciesId or mon.id
if raw == nil then return nil end
if type(raw) == "string" then
local byName = Pokemon.speciesFromName(raw)
if byName then return byName end
local n = tonumber(raw)
if n then raw = n else return nil end
end
local n = tonumber(raw)
if not n or n < 1 then return nil end
-- Prefer internal id when pack has that name; else try national → internal.
if not Pokemon._names then Pokemon.install(Pokemon._cache) end
if Pokemon._names and Pokemon._names[n] and Pokemon._names[n] ~= "??????????" then
-- Ambiguous for Gen3: national 382 is KYOGRE but internal 382 is ARON.
-- Host numeric ids in this project are Gen1/2 range or string names.
if n <= 251 then return n end
-- If national map says this number is a national dex, resolve.
local fromNat = Pokemon.speciesFromNational(n)
if fromNat and fromNat ~= n then
-- Heuristic: if name at n looks like a valid mon and equals national's
-- species name mismatch, prefer national mapping when n > 251.
return fromNat
end
return n
end
return Pokemon.speciesFromNational(n) or n
end
function Pokemon.displayName(mon)
if not mon then return "?????" end
if mon.nickname and mon.nickname ~= "" then return tostring(mon.nickname) end
-- Prefer pack name over host species string when we can resolve.
local sp = Pokemon.speciesOf(mon)
if sp then return Pokemon.name(sp) end
if mon.name and mon.name ~= "" then return tostring(mon.name) end
if type(mon.species) == "string" then return mon.species end
return "?????"
end
return Pokemon