-- A Gen 2 party member: stats, moves, level-up and experience. -- -- Separate from src/pokemon/Pokemon.lua because the struct itself changed: -- Gen 2 splits `special` into Special Attack and Special Defense, adds a held -- item, happiness and pokerus, and its level-up moves come from EvosAttacks -- rather than a Gen 1 learnset table. -- -- Stat formula is unchanged from Gen 1 (data/pokemon/base_stats + DVs): -- stat = floor((base * 2 + DV * 2 + floor(sqrt(statExp) / 4)) * level / 100) + 5 -- HP is the same but + level + 10 -- with the Gen 2 twist that a mon's SpA and SpD share one Special DV, which is -- why a high-Special DV raises both. -- -- Experience curves come from data/growth_rates.asm, whose `growth_rate` macro -- documents its own polynomial: -- [1]/[2] * n^3 + [3] * n^2 + [4] * n - [5] -- with a sign bit on the n^2 term. pokemon.lua carries those five numbers per -- GROWTH_* so this needs no hardcoded table. local Unown = require("src.core.gen2.Unown") -- The mod event bus. pokemon.level_up and pokemon.move_learned are the SAME -- names src/battle/Experience.lua and src/battle/BattleState.lua raise on -- Gen 1, with the same payload keys: a mod that watches a Red party watches a -- Gold one unchanged (docs/mod-api-gen2-compat.md). local Runtime = require("src.mods.Runtime") local Mon = {} Mon.MAX_LEVEL = 100 Mon.PARTY_SIZE = 6 -- DVs are 0..15 each; Attack's low bit pair also decides gender and shininess. Mon.MAX_DV = 15 local function rand(a, b) if love and love.math and love.math.random then return love.math.random(a, b) end return math.random(a, b) end function Mon.randomDVs() return { hp = nil, -- derived below attack = rand(0, Mon.MAX_DV), defense = rand(0, Mon.MAX_DV), speed = rand(0, Mon.MAX_DV), special = rand(0, Mon.MAX_DV), } end -- The HP DV is not stored: it is the low bit of each of the other four -- (Gen 1 and 2 both build it this way), which is why a perfect-HP mon needs -- all four others odd. function Mon.hpDV(dvs) local function bit(value) return (value or 0) % 2 end return bit(dvs.attack) * 8 + bit(dvs.defense) * 4 + bit(dvs.speed) * 2 + bit(dvs.special) end local function statValue(base, dv, level, statExp) local exp = math.floor(math.sqrt(statExp or 0) / 4) return math.floor((((base or 1) * 2 + (dv or 0) * 2 + exp) * level) / 100) + 5 end -- All six stats at a level. `statExp` is optional per-stat effort. function Mon.stats(baseStats, dvs, level, statExp) baseStats = baseStats or {} dvs = dvs or {} statExp = statExp or {} local hpDv = dvs.hp or Mon.hpDV(dvs) local hp = math.floor((((baseStats.hp or 1) * 2 + hpDv * 2 + math.floor(math.sqrt(statExp.hp or 0) / 4)) * level) / 100) + level + 10 return { hp = hp, attack = statValue(baseStats.attack, dvs.attack, level, statExp.attack), defense = statValue(baseStats.defense, dvs.defense, level, statExp.defense), speed = statValue(baseStats.speed, dvs.speed, level, statExp.speed), -- One Special DV feeds both special stats, and so does one Special stat -- exp: the Gen 2 party struct kept Gen 1's five exp words (macros/ram.asm -- box_struct ends them at SpcExp), so SpA and SpD grow together. The -- per-stat keys are still read as a fallback for a record written before -- the shared word existed. specialAttack = statValue(baseStats.specialAttack, dvs.special, level, statExp.special or statExp.specialAttack), specialDefense = statValue(baseStats.specialDefense, dvs.special, level, statExp.special or statExp.specialDefense), } end -- The five stat exp words, in struct order. There is no sixth: see Mon.stats. Mon.STAT_EXP_ORDER = { "hp", "attack", "defense", "speed", "special" } -- Each word is 16 bit and GiveExperiencePoints stops it at $ffff rather than -- letting it wrap (.stat_exp_maxed_out). Mon.MAX_STAT_EXP = 65535 function Mon.newStatExp() return { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 } end -- GiveExperiencePoints' .stat_exp_loop (engine/battle/core.asm): the defeated -- mon's base stats are added to every participant's stat exp, and the loop runs -- NUM_EXP_STATS = 5 times over a six-entry base stat block, so the Special word -- takes the loser's Special ATTACK and the Special Defense base stat is never -- read at all. -- -- `.EvenlyDivideExpAmongParticipants` divides the base stats in place before -- any of this, and only when two or more mons took part, which is why the -- divisor is shared with Mon.experienceGain rather than computed here. -- -- Pokerus adds the same value a SECOND time (`jr z, .stat_exp_awarded` skips -- the second add when the byte is zero) -- doubled, not multiplied by a rate, -- so it stacks with nothing. -- `halved` is the EXP.SHARE tax: with any holder in the party the whole -- wEnemyMon base stat block is `srl`'d in place before EITHER pass runs -- (engine/battle/core.asm, the IsAnyMonHoldingExpShare block ahead of the -- first GiveExperiencePoints call), so participants and holders both draw -- stat exp from the halved values. function Mon.gainStatExp(mon, loserDef, participants, doubled, halved) if type(mon) ~= "table" then return nil end local base = (loserDef and loserDef.baseStats) or {} local share = math.max(1, math.floor(participants or 1)) mon.statExp = mon.statExp or Mon.newStatExp() local gains = {} for _, key in ipairs(Mon.STAT_EXP_ORDER) do local from = (key == "special") and base.specialAttack or base[key] from = from or 0 if halved then from = math.floor(from / 2) end local gain = math.floor(from / share) if doubled then gain = gain * 2 end local value = (mon.statExp[key] or 0) + gain if value > Mon.MAX_STAT_EXP then value = Mon.MAX_STAT_EXP end mon.statExp[key] = value gains[key] = gain end return gains end -- Total experience needed to *be* `level`, from a GROWTH_* record. -- The single point every experience calculation resolves its curve through. -- The merged growth_rates registry wins, then the extractor's own coefficient -- rows on data.pokemon.growthRates, so a mod-free boot reads exactly the table -- it always did and a boot with a registered curve reads that instead. -- -- Both live at the same key space (GROWTH_MEDIUM_FAST and friends), and the -- registry's Data path is the shared `growth_rates` one Gen 1 uses -- the -- point of routing it there rather than to a gen2 table is that a mod writes -- ONE record for both games (src/mods/Builtins.lua's Gen 2 registrant seeds -- this registry from the coefficient rows, so the vanilla curves are Gold's -- own). function Mon.growthFor(data, curve) if not curve then return nil end local registered = data and data.growth_rates and data.growth_rates[curve] if registered then return registered end return data and data.pokemon and data.pokemon.growthRates and data.pokemon.growthRates[curve] end -- Seeds the growth_rates registry with Gold's own curves, as records carrying -- expForLevel so the id space and the record shape both match Gen 1's. Called -- by src/mods/Builtins.lua under Gen 2. A dataset with no coefficient rows -- (the ROM-free fixtures) seeds nothing rather than registering broken curves. function Mon.registerInto(registry, data, owner) local rows = data and data.pokemon and data.pokemon.growthRates if type(rows) ~= "table" then return end for curve, row in pairs(rows) do -- the closure holds the coefficient row, so the registered record computes -- exactly what the arm below would have registry:register(curve, { expForLevel = function(level) return Mon.experienceForLevel(row, level) end, }, owner) end end -- `growth` is either the extractor's coefficient row (numerator / denominator / -- squared / linear / constant, straight off GrowthRates in the ROM) or a -- growth_rates REGISTRY record, which carries expForLevel(level) instead -- -- the same record shape Gen 1's registry uses (src/pokemon/Growth.lua), so a -- mod that registers a custom curve writes one record and it works in both -- games. A registered curve wins outright; the coefficient arm is what a -- mod-free boot and every driver still run. function Mon.experienceForLevel(growth, level) if growth and growth.expForLevel then return math.max(0, math.floor(growth.expForLevel(level) or 0)) end if not growth then return level * level * level end local n = level local numerator = growth.numerator or 1 local denominator = growth.denominator or 1 local value = math.floor(numerator * n * n * n / denominator) value = value + (growth.squared or 0) * n * n value = value + (growth.linear or 0) * n value = value - (growth.constant or 0) return math.max(0, value) end -- The level a total experience buys. Walks up rather than inverting the -- polynomial, which the cart also does (it only ever compares against the next -- level's threshold). function Mon.levelForExperience(growth, experience) local level = 1 while level < Mon.MAX_LEVEL do if experience < Mon.experienceForLevel(growth, level + 1) then break end level = level + 1 end return level end -- The moves a species knows on arrival at `level`: its last four level-up -- moves at or below it (EvosAttacks order, later moves pushing earlier ones -- out, which is what makes a caught mon's moveset deterministic). function Mon.movesAtLevel(def, level, moves) local known = {} for _, entry in ipairs((def and def.levelMoves) or {}) do if entry.level <= level then -- A move already known is not learned twice. local duplicate = false for _, existing in ipairs(known) do if existing == entry.move then duplicate = true break end end if not duplicate then known[#known + 1] = entry.move if #known > 4 then table.remove(known, 1) end end end end local out = {} for _, id in ipairs(known) do local moveDef = moves and moves[id] out[#out + 1] = { id = id, pp = moveDef and moveDef.pp or 0, maxPp = moveDef and moveDef.pp or 0, } end return out end -- Build a party member. `data` needs `pokemon` and `moves`; growth records -- live on data.pokemon.growthRates (written by the extractor). function Mon.new(data, species, level, opts) opts = opts or {} local def = data and data.pokemon and data.pokemon[species] if not def then return nil end level = math.max(1, math.min(Mon.MAX_LEVEL, level or 5)) local dvs = opts.dvs or Mon.randomDVs() dvs.hp = Mon.hpDV(dvs) local statExp = opts.statExp or Mon.newStatExp() local stats = Mon.stats(def.baseStats, dvs, level, statExp) local growth = Mon.growthFor(data, def.growthRate) return { species = species, name = def.name or species, nickname = opts.nickname, level = level, experience = Mon.experienceForLevel(growth, level), dvs = dvs, -- The five stat exp words. A wild or gift mon starts at zero: nothing in -- the cart seeds them, MON_STAT_EXP is zeroed by _MoveMon. statExp = statExp, -- MON_PKRS. Zero is "never infected"; src/core/gen2/Pokerus.lua owns every -- read and write of it after this. pokerus = opts.pokerus or 0, stats = stats, hp = opts.hp or stats.hp, maxHp = stats.hp, types = def.types, moves = opts.moves or Mon.movesAtLevel(def, level, data.moves), -- Held item; wild mons roll one from BaseData's two item slots on the cart, -- which is not modeled yet, so only scripted gifts carry one. item = opts.item, status = nil, -- 70 for a caught mon, 120 for a gift/hatched one. happiness = opts.happiness or 70, caughtLevel = level, -- shiny.roll / gender.roll get the species and level as context; opts.shiny -- still wins, because a FORCED shiny battle (Red Gyarados) is the cart -- overriding the roll rather than a roll to be hooked. shiny = opts.shiny or Mon.isShiny(dvs, { species = species, def = def, level = level }), gender = Mon.gender(def, dvs, { species = species, level = level }), -- Unown has no gender and no shininess worth looking at, but it does have -- a FORM, and the form is the same DVs read a different way -- (GetUnownLetter, engine/gfx/load_pics.asm). Stamped at build time so -- every screen that shows an Unown -- the battle pic, the box, the #DEX -- -- reads one field instead of each redoing the bit shuffle. unownLetter = (species == Unown.SPECIES) and Unown.letterFromDVs(dvs) or nil, } end -- AddPartyMon copies wPlayerName into wPartyMonOTs and wPlayerID into MON_ID -- (move_mon.asm:44-56, :143-149); SendMonIntoBox does the same (:970-994). function Mon.stampOT(save, mon) local player = save and save.player if not (mon and player) then return mon end if player.id == nil then player.id = rand(0, 65535) end mon.ot = mon.ot or player.name -- NpcTrade.lua:150: `ot` is what Breeding reads, `otName` what the summary prints. mon.otName = mon.otName or mon.ot mon.otId = mon.otId or player.id return mon end -- shiny.roll and gender.roll, two of the names Gen 2 invents: Gen 1 has -- neither shininess nor gender in the ROM at all (Red's shiny indicator mods -- read src/pokemon/Stats.lua's virtual pattern, which is a mod-side -- convention, not an engine seam), so there is no Gen 1 name to share. -- -- Both wrap the DV-derived roll rather than the mon that comes out of it, -- because on the cart these ARE the roll: CheckShininess and GetGender read -- the same two DV bytes LoadEnemyMon just generated, and nothing later can -- change the answer without changing the DVs. Wrapping here means a shiny-odds -- mod and a gender-ratio mod work on every route a mon arrives by -- a wild -- encounter, a gift, a hatch, a trade -- because Mon.new is the one builder. -- -- Shared ctx keys: -- dvs the DV set being read, exactly as stored on the mon -- species the species id, nil when the caller had none to give -- def that species' record, nil likewise -- level the level the mon is being built at, nil for a bare query -- -- gender.roll's ctx carries `ratio` as well, BaseData's genderRatio byte, so a -- mod can shift the threshold rather than restate the whole rule. A chain that -- returns something that is not one of "male" / "female" / "unknown" is -- ignored, because every screen that prints a gender indexes by those three. -- Gen 2 shininess: the classic DV pattern (Speed/Defense/Special all 10, and -- Attack in {2,3,6,7,10,11,14,15}). function Mon.vanillaShiny(dvs) if not dvs then return false end if dvs.speed ~= 10 or dvs.defense ~= 10 or dvs.special ~= 10 then return false end local attack = dvs.attack or 0 return attack % 4 == 2 or attack % 4 == 3 end function Mon.isShiny(dvs, ctx) if not Runtime.wantsHook("shiny.roll") then return Mon.vanillaShiny(dvs) end local shiny = Runtime.call("shiny.roll", function(c) return Mon.vanillaShiny(c.dvs) end, { dvs = dvs, species = ctx and ctx.species, def = ctx and ctx.def, level = ctx and ctx.level }) return shiny and true or false end -- Gender comes from the Attack DV against the species' ratio threshold: an -- Attack DV *below* the threshold is female (BaseData's `db GENDER_F12_5` is -- already scaled out of 256). function Mon.vanillaGender(def, dvs) local ratio = def and def.genderRatio if not ratio then return "unknown" end if ratio == 0xff then return "unknown" end -- The DV is 0..15; the threshold is out of 256 in steps of 16. local threshold = math.floor(ratio / 16) return ((dvs and dvs.attack or 0) < threshold) and "female" or "male" end local GENDERS = { male = true, female = true, unknown = true } function Mon.gender(def, dvs, ctx) if not Runtime.wantsHook("gender.roll") then return Mon.vanillaGender(def, dvs) end local gender = Runtime.call("gender.roll", function(c) return Mon.vanillaGender(c.def, c.dvs) end, { def = def, dvs = dvs, ratio = def and def.genderRatio, species = (ctx and ctx.species) or (def and def.id), level = ctx and ctx.level }) if not GENDERS[gender] then return Mon.vanillaGender(def, dvs) end return gender end -- Experience for defeating `loser`, per recipient. Gen 2: -- exp = baseExp * loserLevel / 7, split among the recipients of the pass, -- then GiveExperiencePoints' three BoostExp arms in the cart's own order, -- each a floored x1.5 on the running amount: -- traded the mon's OT id differs from the player's (BoostedExpPointsText) -- trainer a trainer battle (wBattleMode) -- luckyEgg the mon HOLDS a LUCKY_EGG -- checked by item id, not held -- effect, exactly as the cart's `cp LUCKY_EGG` does -- opts.halved is the EXP.SHARE tax: any holder in the party halves the base -- exp byte before either pass (the same `srl` block that halves stat exp). function Mon.experienceGain(loserDef, loserLevel, participants, trainer, opts) opts = opts or {} local baseExp = (loserDef and loserDef.baseExp) or 0 if opts.halved then baseExp = math.floor(baseExp / 2) end local value = math.floor(baseExp * (loserLevel or 1) / 7) value = math.floor(value / math.max(1, participants or 1)) if opts.traded then value = math.floor(value * 3 / 2) end if trainer then value = math.floor(value * 3 / 2) end if opts.luckyEgg then value = math.floor(value * 3 / 2) end return math.max(1, value) end -- Award experience, level up as far as it reaches, and report what happened so -- the battle can print "grew to level N!" and offer new moves. function Mon.gainExperience(mon, amount, data) local def = data and data.pokemon and data.pokemon[mon.species] local growth = Mon.growthFor(data, def and def.growthRate) mon.experience = (mon.experience or 0) + math.max(0, amount or 0) local before = mon.level local capped = Mon.experienceForLevel(growth, Mon.MAX_LEVEL) if mon.experience > capped then mon.experience = capped end local after = Mon.levelForExperience(growth, mon.experience) if after <= before then return { levels = 0, learned = {} } end mon.level = after -- Recompute stats and carry the HP gain, the way the cart adds the delta -- rather than refilling. local previousMax = mon.maxHp or (mon.stats and mon.stats.hp) or 1 mon.stats = Mon.stats(def and def.baseStats, mon.dvs, after, mon.statExp) mon.maxHp = mon.stats.hp mon.hp = math.min(mon.maxHp, (mon.hp or previousMax) + (mon.maxHp - previousMax)) -- pokemon.level_up, once per level crossed and after the stats were -- recalculated, exactly as src/battle/Experience.lua raises it on Gen 1 -- -- a jump of three levels is three events, not one. `learnable` is the moves -- this species learns at exactly that level, the same list Gen 1 carries. if Runtime.wants("pokemon.level_up") then for level = before + 1, after do local learnable = {} for _, entry in ipairs((def and def.levelMoves) or {}) do if entry.level == level then learnable[#learnable + 1] = entry.move end end Runtime.emit("pokemon.level_up", { mon = mon, level = level, prevLevel = level - 1, learnable = learnable, }) end end -- Every level-up move between the old and new level is offered. local learned = {} for _, entry in ipairs((def and def.levelMoves) or {}) do if entry.level > before and entry.level <= after then learned[#learned + 1] = entry.move end end return { levels = after - before, learned = learned, from = before, to = after } end -- Teach a move, or report that all four slots are full so the caller can ask -- which to forget. function Mon.learnMove(mon, moveId, data) mon.moves = mon.moves or {} for _, move in ipairs(mon.moves) do if move.id == moveId then return false, "known" end end local def = data and data.moves and data.moves[moveId] local entry = { id = moveId, pp = def and def.pp or 0, maxPp = def and def.pp or 0, } if #mon.moves >= 4 then return false, "full", entry end mon.moves[#mon.moves + 1] = entry -- pokemon.move_learned, the payload BattleState:learnMove emits on Gen 1. -- This is Gen 2's single choke point for teaching a move -- the level-up -- award, an evolution's new move and the TM path all arrive here -- so the -- event covers all three rather than only the battle's. Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) return true end -- Which evolution (if any) fires at this level. function Mon.evolutionAtLevel(def, level) for _, entry in ipairs((def and def.evolutions) or {}) do if entry.method == "EVOLVE_LEVEL" and (entry.level or 0) <= level then return entry end end return nil end return Mon