G2 support

This commit is contained in:
bryanthaboi
2026-08-11 11:52:56 -04:00
parent 79ed37699e
commit ae6cac89e1
489 changed files with 226677 additions and 1798 deletions
+401
View File
@@ -0,0 +1,401 @@
-- Gen 2 catch rate (engine/items/item_effects.asm PokeBallEffect).
--
-- The rate itself, transcribed from the ASM:
--
-- rate = ((3 * maxHP - 2 * curHP) * ballAdjustedCatchRate) / (3 * maxHP)
-- rate = max(1, rate) + statusBonus
-- rate = min(255, rate)
--
-- Two documented cart bugs are reproduced deliberately, because a port that
-- "fixes" them catches mons at rates the real game never would (both are in
-- pokegold's docs/bugs_and_glitches.md):
--
-- * When 3 * maxHP >= 256 the routine shifts both HP terms right by two
-- before subtracting, which loses precision and makes the formula
-- misbehave for maxHP above 341.
-- * The status bonus was meant to be 10 for sleep/freeze and 5 for
-- burn/poison/paralysis, but the `and` that tests for sleep/freeze leaves
-- the accumulator zero on the fall-through, so burn, poison and paralysis
-- give no bonus at all.
--
-- Pass `fixBugs = true` to get the intended behaviour instead; nothing in the
-- game sets it, but it makes the difference testable and documents intent.
-- The mod event/hook buses. `catch.rate` and `battle.ball_thrown` are the SAME
-- names src/battle/BattleState.lua raises on Gen 1, with the same argument
-- order and the same payload keys (docs/mod-api-gen2-compat.md).
local Runtime = require("src.mods.Runtime")
local Catching = {}
-- Ball multipliers applied to the species catch rate before the HP term.
-- MASTER_BALL never fails, so it short-circuits rather than multiplying.
-- The specialty balls (BallMultiplierFunctionTable) are conditional and live
-- in Catching.specialtyRate below; FRIEND_BALL has no rate function at all --
-- its whole effect is the caught mon's happiness, which the catch site sets.
Catching.BALL_MULTIPLIER = {
MASTER_BALL = math.huge,
ULTRA_BALL = 2,
-- SafariBallMultiplier, GreatBallMultiplier and ParkBallMultiplier are one
-- shared routine on the cart (x1.5); Safari is the RBY leftover.
GREAT_BALL = 1.5,
POKE_BALL = 1,
SAFARI_BALL = 1.5,
PARK_BALL = 1.5,
FRIEND_BALL = 1,
}
-- FastBallMultiplier (engine/items/item_effects.asm): meant to cover all
-- three FleeMons tables, but the loop advances `d` on every byte instead of
-- every table (`jr nz, .next` where the intended jump is `.loop`), so only
-- the first three rows of SometimesFleeMons (data/wild/flee_mons.asm) ever
-- get the x4. Reproduced deliberately, like the two catch-formula bugs.
Catching.FAST_BALL_SPECIES = {
MAGNEMITE = true, GRIMER = true, TANGELA = true,
}
-- FRIEND_BALL_HAPPINESS (constants/pokemon_data_constants.asm): the one thing
-- a Friend Ball does. The catch site stamps it on the caught mon.
Catching.FRIEND_BALL_HAPPINESS = 200
-- HeavyBallMultiplier's weight conversion: the dex weight (tenths of a
-- pound) is turned into tenths of a kilogram with three shift-subtracts
-- (w/2 - w/32 - w/64), and only the HIGH byte of that is compared.
function Catching.heavyBallBoost(weight)
local half = math.floor((weight or 0) / 2)
local sub1 = math.floor(half / 16)
local sub2 = math.floor(sub1 / 2)
local high = math.floor((half - sub1 - sub2) / 256)
if high < 4 then return -20 end -- under 102.4 kg
if high < 8 then return 0 end -- under 204.8 kg
if high < 12 then return 20 end -- under 307.2 kg
if high < 16 then return 30 end -- under 409.6 kg
return 40
end
-- The conditional balls (engine/items/item_effects.asm
-- BallMultiplierFunctionTable, HeavyBallMultiplier..FastBallMultiplier).
-- `rate` is the species catch rate byte; every arm caps at 255 the way each
-- `sla b / jr c` pins $ff. Three cart bugs are reproduced deliberately (all
-- in pokegold's own comments): Fast Ball only knows three species, Love Ball
-- boosts SAME-sex pairs, and Moon Ball compares the evolution stone against
-- Gen 1's Moon Stone constant -- Burn Heal in Gen 2 -- so it never boosts.
-- `fixBugs` flips all three to the intended behaviour, same contract as the
-- catch-formula bugs above.
-- One arm per row of BallMultiplierFunctionTable, each fn(rate, opts) -> rate.
-- The records below hang these on `specialty` by identity, so a registry read
-- and Catching.specialtyRate can never answer differently.
local SPECIALTY = {
HEAVY_BALL = function(rate, opts)
-- Additive, not a multiplier; the light-mon subtraction floors at 1
-- (`ld b, $1` on underflow).
if not opts.weight then return rate end
return math.max(1, rate + Catching.heavyBallBoost(opts.weight))
end,
LEVEL_BALL = function(rate, opts)
-- x2 / x4 / x8 as the wild level falls below the player's level, its
-- half and its quarter (strictly below at each rung).
local player = opts.playerLevel
local enemy = opts.level
if not (player and enemy) or enemy >= player then return rate end
rate = rate * 2
if enemy < math.floor(player / 2) then rate = rate * 2 end
if enemy < math.floor(player / 4) then rate = rate * 2 end
return math.min(255, rate)
end,
LURE_BALL = function(rate, opts)
-- x3, only in a BATTLETYPE_FISH battle.
if not opts.fishing then return rate end
return math.min(255, rate * 3)
end,
FAST_BALL = function(rate, opts)
if opts.fixBugs then
if not opts.fleeing then return rate end
elseif not Catching.FAST_BALL_SPECIES[opts.species] then
return rate
end
return math.min(255, rate * 4)
end,
MOON_BALL = function(rate, opts)
-- MOON_STONE_RED is BURN_HEAL's Gen 2 id and nothing evolves with a
-- Burn Heal, so the intended x4 never happens on the cart.
local wanted = opts.fixBugs and "MOON_STONE" or "BURN_HEAL"
if opts.evolveItem ~= wanted then return rate end
return math.min(255, rate * 4)
end,
LOVE_BALL = function(rate, opts)
-- x8 for the same species; the sex test's `ret nz` should be `ret z`,
-- so the boost lands on SAME-sex pairs. Genderless mons never boost.
if not opts.species or opts.species ~= opts.playerSpecies then
return rate
end
local wild, player = opts.gender, opts.playerGender
if not wild or not player or wild == "unknown"
or player == "unknown" then
return rate
end
local same = wild == player
if opts.fixBugs then same = not same end
if not same then return rate end
return math.min(255, rate * 8)
end,
}
function Catching.specialtyRate(rate, ball, opts)
opts = opts or {}
local arm = SPECIALTY[ball]
if not arm then return rate end
return arm(rate, opts)
end
Catching.STATUS_BONUS = { sleep = 10, freeze = 10 }
Catching.STATUS_BONUS_FIXED = {
sleep = 10, freeze = 10, burn = 5, poison = 5, toxic = 5, paralyze = 5,
}
-- ------------------------------------------------------------------ registry
--
-- Gold's own ball records, in the shape src/mods/Schemas.lua's `balls` registry
-- validates -- the SAME registry name Gen 1 fills from src/battle/Catching.lua,
-- because a mod that adds a ball should not have to learn a second noun. The
-- Gen 1 fields keep their Gen 1 meaning where Gen 2 has one:
--
-- randMax the ceiling of the catch roll. PokeBallEffect rolls ONE byte
-- against wFinalCatchRate, so every rolling ball is 255 here;
-- Gen 1's per-ball 200/150 ceilings have no Gen 2 counterpart.
-- autoCatch MASTER_BALL, which returns before the rate is computed.
-- flicker DoBallTossSpecialEffects' OBJ-palette strobe, Master and Ultra.
--
-- hpFactor / wobbleFactor / tossAnim are deliberately absent: Gen 2 decides the
-- wobble count inside the animation (GetPokeBallWobble re-rolls per wobble), so
-- there is no ballFactor2 to carry, and the toss arc is the animation's.
--
-- Two fields Gen 2 genuinely carries that Gen 1 does not, added rather than
-- renaming anything (the catalog's top-level records are extensible):
--
-- multiplier the flat factor BallMultiplierFunctionTable applies to the
-- species catch rate. math.huge is the Master Ball's "never
-- fails" and pairs with autoCatch.
-- specialty the conditional arm, fn(rate, opts) -> rate, for the balls
-- whose factor depends on the battle rather than the ball.
-- Present exactly where BALL_MULTIPLIER has no row.
--
-- FRIEND_BALL keeps multiplier 1 and carries its one real effect as
-- catchHappiness; src/ui/gen2/BattleState.lua stamps it on the caught mon.
Catching.BALLS = {
MASTER_BALL = { randMax = 0, autoCatch = true, flicker = true,
multiplier = Catching.BALL_MULTIPLIER.MASTER_BALL },
ULTRA_BALL = { randMax = 255, flicker = true,
multiplier = Catching.BALL_MULTIPLIER.ULTRA_BALL },
GREAT_BALL = { randMax = 255,
multiplier = Catching.BALL_MULTIPLIER.GREAT_BALL },
POKE_BALL = { randMax = 255,
multiplier = Catching.BALL_MULTIPLIER.POKE_BALL },
SAFARI_BALL = { randMax = 255,
multiplier = Catching.BALL_MULTIPLIER.SAFARI_BALL },
PARK_BALL = { randMax = 255,
multiplier = Catching.BALL_MULTIPLIER.PARK_BALL },
FRIEND_BALL = { randMax = 255,
multiplier = Catching.BALL_MULTIPLIER.FRIEND_BALL,
catchHappiness = Catching.FRIEND_BALL_HAPPINESS },
HEAVY_BALL = { randMax = 255 },
LEVEL_BALL = { randMax = 255 },
LURE_BALL = { randMax = 255 },
FAST_BALL = { randMax = 255 },
MOON_BALL = { randMax = 255 },
LOVE_BALL = { randMax = 255 },
}
-- the conditional arms, hung on the records they belong to by identity so a
-- registry read and Catching.specialtyRate cannot answer differently
for id, arm in pairs(SPECIALTY) do
Catching.BALLS[id].specialty = arm
end
-- vanilla registrations, engine-owned (Schemas.ENGINE), so a mod's register of
-- one of these ids collides the way it does on Red and has to say override
function Catching.registerInto(registry, _, owner)
for id, record in pairs(Catching.BALLS) do
registry:register(id, record, owner)
end
end
-- The merged `balls` table for this boot, or nil. Every catch site hands the
-- module what it already holds -- a Battle (src/battle/gen2/Battle.lua keeps
-- `data`), the data table itself, or the merged subtable -- so no state has to
-- live on this module.
local function mergedBalls(opts)
if not opts then return nil end
if opts.balls then return opts.balls end
local data = opts.data or (opts.battle and opts.battle.data)
return data and data.gen2Balls or nil
end
-- The merged record for a ball id, the module's own when no loader ran. An
-- unknown id answers nil on both paths: PokeBallEffect's table has no default
-- row, and the rate math below leaves the species rate alone for one.
function Catching.recordFor(ball, opts)
local merged = mergedBalls(opts)
local record = merged and merged[ball]
if record then return record end
return Catching.BALLS[ball]
end
local function rand(random, n)
if random then return random(n) end
if love and love.math and love.math.random then
return love.math.random(n) - 1
end
return math.random(n) - 1
end
-- The 0..255 rate. `opts`: maxHp, hp, catchRate, ball, status, fixBugs;
-- the specialty-ball conditions ride the same table (weight, level,
-- playerLevel, fishing, species, playerSpecies, gender, playerGender,
-- evolveItem), each supplied by the catch site out of what it already knows.
--
-- One more optional key and it is the registry seam: `data` (or `battle`, or
-- the merged `balls` / `statuses` subtables directly). A catch site that
-- passes it gets the merged records, so a mod's ball and a mod's status reach
-- the roll; one that does not gets the module's own, which is what every
-- pure-module test and every loader-free boot has always had.
function Catching.rate(opts)
opts = opts or {}
local ball = opts.ball or "POKE_BALL"
-- Through the merged `balls` registry, module records when no loader ran.
-- The three arms are exactly the three rows the record can carry: a flat
-- multiplier, a conditional arm, or neither -- and "neither" is also what an
-- unknown ball id gets, which is BallMultiplierFunctionTable's own answer of
-- leaving the species rate alone.
local record = Catching.recordFor(ball, opts)
local multiplier = record and record.multiplier
if record and record.autoCatch then return 255, true end
if multiplier == math.huge then return 255, true end
local maxHp = math.max(1, opts.maxHp or 1)
local hp = math.max(0, math.min(opts.hp or maxHp, maxHp))
local catchRate
if multiplier then
catchRate = math.floor((opts.catchRate or 45) * multiplier)
elseif record and record.specialty then
catchRate = record.specialty(opts.catchRate or 45, opts)
else
catchRate = opts.catchRate or 45
end
catchRate = math.max(1, math.min(255, catchRate))
local tripleMax = maxHp * 3
local doubleHp = hp * 2
if tripleMax >= 256 then
-- The cart's precision loss: both terms shift right two bits.
tripleMax = math.floor(tripleMax / 4)
doubleHp = math.floor(doubleHp / 4)
if not opts.fixBugs then
-- And it then compares only the low byte of the shifted max.
tripleMax = tripleMax % 256
end
doubleHp = math.max(1, doubleHp)
end
tripleMax = math.max(1, tripleMax)
local rate = math.floor((tripleMax - doubleHp) * catchRate / tripleMax)
rate = math.max(1, rate)
rate = rate + Catching.statusBonus(opts.status, opts)
return math.min(255, rate), false
end
-- The status half of the rate, off the merged `statuses` record the same way
-- src/battle/Catching.lua reads record.catchBonus on Gen 1. Gold's records
-- live on src/battle/gen2/Battle.lua (Battle.STATUSES) and carry BOTH numbers:
-- `catchBonus` is what the cart actually adds (the `and` that tests for
-- sleep/freeze leaves burn, poison and paralysis at zero) and
-- `catchBonusIntended` is the 5 the table meant to give them, which is what
-- `fixBugs` asks for. The two module tables answer when no loader ran.
function Catching.statusBonus(status, opts)
if not status then return 0 end
local data = opts and (opts.data or (opts.battle and opts.battle.data))
local statuses = (opts and opts.statuses) or (data and data.gen2Statuses)
local record = statuses and statuses[status]
if record then
if opts and opts.fixBugs then
return record.catchBonusIntended or record.catchBonus or 0
end
return record.catchBonus or 0
end
local bonuses = (opts and opts.fixBugs) and Catching.STATUS_BONUS_FIXED
or Catching.STATUS_BONUS
return bonuses[status] or 0
end
-- Does the ball catch? Returns caught and the final rate (wFinalCatchRate).
-- A rate of 255 or a Master Ball is certain.
--
-- No wobble count comes out of here: unlike Gen 1, Gen 2 decides how many times
-- the ball rocks DURING the animation. GetPokeBallWobble
-- (engine/battle_anims/pokeball_wobble.asm) is called once per wobble and
-- re-rolls Random against the WobbleProbabilities row that wFinalCatchRate
-- picks, so the count is a property of the animation loop and not of how near
-- the catch roll was. The caller runs that loop with the rate returned here.
function Catching.attempt(opts)
opts = opts or {}
local caught, rate
if Runtime.wantsHook("catch.rate") then
-- catch.rate, the same hook BattleState:catchAttempt calls on Gen 1 and
-- with the same four arguments: the ball id, the target mon, its species
-- record, and the options table vanilla is actually run on -- so a mod
-- that edits o.catchRate or o.status changes the roll exactly as it does
-- on Red, and one that returns `caught, rate` replaces it outright.
--
-- `mon` and `def` are whatever the catch site supplied. Gold's battle
-- screen (src/ui/gen2/BattleState.lua) hands this module a FLAT opts table
-- -- hp, maxHp, catchRate, status, species -- rather than the mon and its
-- record, so both are nil there until it passes them; nil, not a stand-in
-- a mod would read as the real mon. Passing `battle = self.battle` and
-- `mon = enemy` with them is what puts the capture tail below on the real
-- catch: the Transform reload and the battle.catch_exp hook both hang off
-- the battle, and neither can be reached from a flat table.
caught, rate = Runtime.call("catch.rate", function(_, _, _, o)
return Catching.vanillaAttempt(o)
end, opts.ball or "POKE_BALL", opts.mon, opts.def, opts)
else
caught, rate = Catching.vanillaAttempt(opts)
end
-- PokeBallEffect's captured tail runs BEFORE the mon is added to anything
-- (item_effects.asm:514-566), so a caught mon is reloaded out of its base
-- data -- and the battle.catch_exp hook is asked -- here, while the record
-- the catch site is about to keep is still the battle's. Both live on
-- Battle:caught; this is the seam that reaches it, and it is only reachable
-- when the site hands over the battle it is catching out of (see the note on
-- `mon` below). A caller with no battle -- every pure-module test -- gets
-- the roll and nothing else, exactly as before.
if caught and opts.battle and opts.battle.caught then
opts.battle:caught(opts.mon)
end
-- battle.ball_thrown, the payload BattleState:throwBall emits on Gen 1.
-- `shakes` is deliberately nil rather than 0: Gen 2 does not decide the
-- wobble count here at all (see the note on Catching.attempt above -- the
-- animation loop re-rolls GetPokeBallWobble per wobble), so there is no
-- number to report at throw time and a 0 would read as "it did not rock".
-- `rate` is the Gen 2 addition, the wFinalCatchRate the animation runs on.
if Runtime.wants("battle.ball_thrown") then
Runtime.emit("battle.ball_thrown", {
battle = opts.battle, ball = opts.ball or "POKE_BALL",
caught = caught, shakes = nil, rate = rate,
mon = opts.mon, species = opts.species,
})
end
return caught, rate
end
function Catching.vanillaAttempt(opts)
local rate, guaranteed = Catching.rate(opts)
local random = opts and opts.random
if guaranteed or rate >= 255 then return true, rate end
-- The cart rolls one byte against the rate; a roll under it catches.
local roll = rand(random, 256)
if roll < rate then return true, rate end
return false, rate
end
return Catching