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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+480
View File
@@ -0,0 +1,480 @@
-- The Gen 2 battle-animation command interpreter.
--
-- pokegold engine/battle_anims/anim_commands.asm: RunBattleAnimScript's frame
-- loop and the 48-entry BattleAnimCommands jumptable it dispatches through.
-- The scripts themselves are already disassembled into the cache by
-- RomExtractorGen2 (`data/generated/battle_anims.lua`), keyed by their ROM
-- address because that is what a branch names.
--
-- One frame of an animation is exactly three things, in this order:
--
-- RunBattleAnimCommand run script bytes until one asks to wait
-- ExecuteBGEffects one pass over the five BG-effect structs
-- BattleAnim_UpdateOAM_All one pass over the ten object structs
--
-- so `step()` here is one 60 Hz frame and nothing else needs a clock. The
-- animation is over when a `ret` runs outside a subroutine, which is what
-- BATTLEANIM_STOP_F means.
--
-- Two things about the script format that are easy to get wrong and are
-- already handled by the extractor, repeated here because this is where they
-- bite: anything under $d0 is `anim_wait <n>` and carries no arguments, and a
-- branch's target is the LAST two bytes of the command, which the extractor
-- has already rewritten into a pool key.
--
-- Love-free: sound and cries go out through the `hooks` table the battle
-- screen supplies, so a test can step a whole animation and assert what it
-- asked to play.
local bit = require("bit")
local AnimObjects = require("src.battle.gen2.AnimObjects")
local BgEffects = require("src.battle.gen2.BgEffects")
local AnimRunner = {}
local Runner = {}
Runner.__index = Runner
-- wBattleAnimTileDict is five {gfx id, tile id} pairs.
local NUM_TILEDICT_ENTRIES = 5
-- BATTLEANIM_BASE_TILE is 7*7; the sheets share the tiles above it up to
-- vTiles1, so the running allocator stops at 128 - 49.
local MAX_ANIM_TILES = 128 - 49
-- BattleAnimCmd_BattlerGFX_*: the battlers' own pic tiles are registered in
-- the dict at fixed ids rather than loaded from AnimObjGFX. These are the
-- ASM's `($80 - 6 - 7) - BATTLEANIM_BASE_TILE` and friends.
local BATTLER_TILES = {
oneRow = { player = (0x80 - 6 - 7) - 49, enemy = (0x80 - 6) - 49 },
twoRow = { player = (0x80 - 6 * 2 - 7 * 2) - 49, enemy = (0x80 - 6 * 2) - 49 },
}
-- BattleAnimCmd_Cry's .CryData: a pitch and a length added to the mon's own
-- cry, indexed by the command's argument masked to NUM_NOISE_CHANS.
local CRY_DATA = {
[0] = { pitch = 0x0000, length = 0x00c0 },
[1] = { pitch = 0x0000, length = 0x0040 },
[2] = { pitch = 0x0000, length = 0x0000 },
[3] = { pitch = 0x0000, length = 0x0000 },
}
-- BattleAnimCmd_Sound's .GetPanning, indexed by the cry-track pair.
local PANNING = { [0] = 0xf0, [1] = 0x0f, [2] = 0xf0, [3] = 0x0f }
--------------------------------------------------------------------------
-- opts:
-- data the cache's battle_anims.lua
-- constants the cache's constants.lua
-- battleTurn hBattleTurn -- 0 while the player is attacking
-- param wBattleAnimParam, which the effect layer sets (hit count,
-- stat direction, the Beat Up party slot...)
-- animId the move (or ANIM_* id) whose script this is
-- hooks { sound(name, panning, duration), cry(side, pitch, length) }
-- ballPalette the PAL_BATTLE_OB_* name for the ball being thrown
function AnimRunner.new(opts)
opts = opts or {}
local self = setmetatable({}, Runner)
self.data = opts.data or {}
self.constants = opts.constants or {}
self.hooks = opts.hooks or {}
-- Shared by both pools; the object functions and the BG effects read the
-- same hBattleTurn.
self.env = {
battleTurn = opts.battleTurn or 0,
animId = opts.animId,
ballPalette = opts.ballPalette,
sgb = opts.sgb,
flying = opts.flying or {},
}
self.objects = AnimObjects.new(self.data, self.constants, self.env)
self.bg = BgEffects.new(self.constants, self.env)
self.gfxOrder = self.constants.battleAnimGfxOrder or {}
self.sfxOrder = opts.sfxOrder or {}
self.param = opts.param or 0 -- wBattleAnimParam
self.var = 0 -- wBattleAnimVar
self.delay = 0 -- wBattleAnimDelay
self.loops = 0 -- wBattleAnimLoops
self.inSubroutine = false
self.inLoop = false
self.stopped = false
self.keepSprites = false
self.frames = 0
-- wBattleAnimTileDict, and the sheets it points at.
self.tileDict = {}
self.loaded = {}
-- Set by the substitute / minimize / transform commands, for the view.
self.picOverride = { player = nil, enemy = nil }
self.address = nil
self.parent = nil
return self
end
-- ClearBattleAnims: the whole animation block, then the entry point.
function Runner:start(scriptKey)
self.objects:clear()
self.bg:reset()
self.var, self.delay, self.loops = 0, 0, 0
self.inSubroutine, self.inLoop, self.stopped = false, false, false
self.keepSprites = false
self.frames = 0
self.tileDict = {}
self.loaded = {}
self.picOverride = { player = nil, enemy = nil }
self.address = scriptKey and { key = scriptKey, index = 1 } or nil
self.parent = nil
return self
end
-- The script for a move, or nil when the cache has none (which is what an
-- unextracted or modded move looks like).
function AnimRunner.scriptForMove(data, moveId)
local moves = (data or {}).moves or {}
return moves[moveId]
end
function Runner:scriptRows(key)
return (self.data.scripts or {})[key]
end
-- GetBattleAnimByte, one decoded row at a time.
function Runner:fetch()
local at = self.address
if not at then return nil end
local rows = self:scriptRows(at.key)
if not rows then return nil end
local row = rows[at.index]
if not row then return nil end
at.index = at.index + 1
return row
end
-- The three "skip the branch target" tails: a conditional that does not take
-- its branch steps the address past the two address bytes, which in a decoded
-- row list is simply "carry on".
function Runner:jumpTo(key)
self.address = { key = key, index = 1 }
end
--------------------------------------------------------------------------
-- The tile dict
--------------------------------------------------------------------------
-- GetBattleAnimTileOffset: the dict is scanned for the gfx id and its tile
-- returned; a miss is 0, which is why an object whose sheet the script never
-- loaded draws whatever happens to sit at the base tile.
function Runner:tileOffsetFor(gfxId)
for i = 1, NUM_TILEDICT_ENTRIES do
local entry = self.tileDict[i]
if entry and entry.gfx == gfxId then return entry.tile end
end
return 0
end
-- BattleAnimCmd_1GFX..5GFX. The running tile id restarts at 0 for every
-- command and each sheet is laid down after the last, so two animations that
-- load different sheet counts do not agree about where anything is -- which
-- is exactly why the dict exists. Entries past the count are NOT cleared.
function Runner:loadGfx(names)
local tile = 0
for slot, gfxId in ipairs(names) do
if tile >= MAX_ANIM_TILES then break end
local name = gfxId
if type(gfxId) == "number" then
name = self.gfxOrder[gfxId + 1] or gfxId
end
self.tileDict[slot] = { gfx = name, tile = tile }
local sheet = (self.data.gfx or {})[name]
self.loaded[#self.loaded + 1] = {
gfx = name, tile = tile, tiles = (sheet and sheet.tiles) or 0,
}
tile = tile + ((sheet and sheet.tiles) or 0)
end
end
-- BattleAnimCmd_BattlerGFX_1Row / _2Row. The battlers' pic tiles are
-- APPENDED after whatever the script already loaded rather than replacing it,
-- and they always land on the same two fixed tile ids.
--
-- (pokegold's jumptable has these two labels the other way round from the
-- macro names -- $d9 dispatches to BattleAnimCmd_BattlerGFX_1Row while
-- anim_battlergfx_2row is $d9 -- so the names below follow the MACRO, which
-- is what a script actually writes.)
function Runner:loadBattlerGfx(rows)
local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow
local slot = 1
while slot <= NUM_TILEDICT_ENTRIES and self.tileDict[slot] do
slot = slot + 1
end
if slot + 1 > NUM_TILEDICT_ENTRIES then return end
self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player }
self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy }
self.loaded[#self.loaded + 1] =
{ gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 6,
battler = "player", rows = rows }
self.loaded[#self.loaded + 1] =
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 7,
battler = "enemy", rows = rows }
end
--------------------------------------------------------------------------
-- BattleAnimCommands
--------------------------------------------------------------------------
local C = {}
C.obj = function(self, row)
self.objects:queue(row[2], row[3], row[4], row[5], function(gfx)
return self:tileOffsetFor(gfx)
end)
end
for count = 1, 5 do
C[count .. "gfx"] = function(self, row)
local names = {}
for i = 1, count do names[i] = row[i + 1] end
self:loadGfx(names)
end
end
C.incobj = function(self, row)
local st = self.objects:findByIndex(row[2])
if st then st.jt = AnimObjects.u8(st.jt + 1) end
end
C.setobj = function(self, row)
local st = self.objects:findByIndex(row[2])
if st then st.jt = AnimObjects.u8(row[3]) end
end
C.incbgeffect = function(self, row) self.bg:incEffect(row[2]) end
C.battlergfx_1row = function(self) self:loadBattlerGfx(1) end
C.battlergfx_2row = function(self) self:loadBattlerGfx(2) end
-- GetPokeBallWobble's answer, which the ball's own script then branches on.
C.checkpokeball = function(self)
self.var = self.hooks.pokeballWobble and self.hooks.pokeballWobble() or 0
end
-- The commands that swap a battler's pic out for something else. The port
-- records which, and the view draws it.
--
-- Every one of them branches on hBattleTurn the same way
-- (engine/battle_anims/anim_commands.asm): `and a / jr z, .player`, and the
-- .player arm is the one that writes vTiles2 tile $31, the 6x6 BACKPIC slot.
-- So turn 0, the player attacking, always repaints the PLAYER's own pic, and
-- the fall-through arm (tile $00, the 7x7 frontpic) repaints the enemy's.
C.transform = function(self)
-- BattleAnimCmd_Transform: .player loads wTempEnemyMonSpecies into the
-- backpic slot, i.e. the player's sprite becomes what it transformed into.
local side = self.env.battleTurn == 0 and "player" or "enemy"
self.picOverride[side] = "transform"
end
C.raisesub = function(self)
local side = self.env.battleTurn == 0 and "player" or "enemy"
self.picOverride[side] = "substitute"
end
C.dropsub = function(self)
local side = self.env.battleTurn == 0 and "player" or "enemy"
self.picOverride[side] = nil
end
-- BattleAnimCmd_MinimizeOpp / GetMinimizePic: despite the name it shrinks the
-- ATTACKER, because .player (turn 0) requests the 6x6 block at tile $31. The
-- other minimize opcode, $e9, is one of the dummies below.
C.minimizeopp = function(self)
local side = self.env.battleTurn == 0 and "player" or "enemy"
self.picOverride[side] = "minimize"
end
C.beatup = function(self)
-- wBattleAnimParam is the party slot whose pic to show.
local side = self.env.battleTurn == 0 and "player" or "enemy"
self.picOverride[side] = { kind = "beatup", slot = self.param }
end
C.resetobp0 = function(self)
self.bg.obp0 = self.env.sgb and 0xf0 or 0xe0
end
C.sound = function(self, row)
local packed = row[2] or 0
-- The first byte is BOTH the duration (its top six bits) and the cry-track
-- pair (its bottom two), which is why the same value reads twice here.
local duration = bit.rshift(packed, 2)
local tracks = bit.band(packed, 3)
if self.env.battleTurn ~= 0 then tracks = bit.bxor(tracks, 1) end
local id = row[3] or 0
local name = self.sfxOrder[id + 1]
if self.hooks.sound then
self.hooks.sound(name, PANNING[tracks] or 0xff, duration, id)
end
end
C.cry = function(self, row)
local slot = bit.band(row[2] or 0, 3)
local entry = CRY_DATA[slot] or CRY_DATA[0]
local side = self.env.battleTurn == 0 and "player" or "enemy"
if self.hooks.cry then self.hooks.cry(side, entry.pitch, entry.length) end
end
C.clearobjs = function(self) self.objects:clearObjs() end
C.oamon = function() end
C.oamoff = function() end
C.updateactorpic = function() end
-- $e7 and $e8-$ed are `ret` on the cart; $f5-$f7 too. $e9 is `minimize`, and
-- it really is one of them: BattleAnimCmd_E8 through BattleAnimCmd_ED are six
-- labels stacked on a single `ret` (engine/battle_anims/anim_commands.asm).
-- The minimize animation that is actually drawn is $e2, minimizeopp above.
C.minimize = function() end
C.unknown_e7 = function() end
C.unknown_ea = function() end
C.unknown_eb = function() end
C.unknown_ec = function() end
C.unknown_ed = function() end
C.unknown_f5 = function() end
C.unknown_f6 = function() end
C.unknown_f7 = function() end
C.keepsprites = function(self) self.keepSprites = true end
C.bgp = function(self, row) self.bg.bgp = row[2] end
C.obp0 = function(self, row) self.bg.obp0 = row[2] end
C.obp1 = function(self, row) self.bg.obp1 = row[2] end
C.bgeffect = function(self, row)
self.bg:queue(row[2], row[3], row[4], row[5])
end
C.setvar = function(self, row) self.var = AnimObjects.u8(row[2]) end
C.incvar = function(self) self.var = AnimObjects.u8(self.var + 1) end
C.if_var_equal = function(self, row)
if row[2] == self.var then self:jumpTo(row[3]) end
end
C.if_param_equal = function(self, row)
if row[2] == self.param then self:jumpTo(row[3]) end
end
C.if_param_and = function(self, row)
if bit.band(self.param, row[2] or 0) ~= 0 then self:jumpTo(row[3]) end
end
-- The one conditional that CONSUMES what it tests: each pass decrements
-- wBattleAnimParam, so `anim_jumpuntil` runs its block param times.
C.jumpuntil = function(self, row)
if self.param == 0 then return end
self.param = AnimObjects.u8(self.param - 1)
self:jumpTo(row[2])
end
C.jump = function(self, row) self:jumpTo(row[2]) end
C.loop = function(self, row)
local count = row[2] or 0
if not self.inLoop then
-- A count of 0 loops forever and never claims the loop flag.
if count ~= 0 then
self.inLoop = true
self.loops = AnimObjects.u8(count - 1)
end
self:jumpTo(row[3])
return
end
if self.loops == 0 then
self.inLoop = false
return -- falls through past the target
end
self.loops = self.loops - 1
self:jumpTo(row[3])
end
C.call = function(self, row)
self.parent = { key = self.address.key, index = self.address.index }
self.inSubroutine = true
self:jumpTo(row[2])
end
C.ret = function(self)
self.inSubroutine = false
self.address = self.parent
and { key = self.parent.key, index = self.parent.index } or nil
end
--------------------------------------------------------------------------
-- RunBattleAnimCommand: burn the delay, otherwise run script rows until one
-- of them asks to wait or the animation ends.
function Runner:runCommands()
if self.delay ~= 0 then
self.delay = self.delay - 1
return
end
for _ = 1, 512 do
local row = self:fetch()
if not row then
self.stopped = true
return
end
local cmd = row[1]
if cmd == "ret" then
-- A `ret` outside a subroutine is what ends the whole animation.
if not self.inSubroutine then
self.stopped = true
return
end
C.ret(self, row)
elseif cmd == "wait" then
self.delay = row[2] or 0
return
else
local fn = C[cmd]
if fn then fn(self, row) end
end
end
-- A script that never waits would hang the battle; stopping is the only
-- honest thing to do with one.
self.stopped = true
end
-- One frame. Returns false once the animation is over.
function Runner:step()
if self.stopped then return false end
self.frames = self.frames + 1
self:runCommands()
self.bg:playFrame()
-- A BG effect can ask for an object (the battler-pic ones do), and it has
-- to land before the object pass or it would be a frame late.
for _, spawn in ipairs(self.bg:takeSpawns()) do
self.objects:queue(spawn.object, spawn.x, spawn.y, spawn.param,
function(gfx) return self:tileOffsetFor(gfx) end)
end
self.objects:playFrame()
-- Rollout hands the shake to the first object's Y offset.
if self.bg.rolloutYOffset then
local first = self.objects.structs[1]
if first and first.index ~= 0 then first.yOffset = self.bg.rolloutYOffset end
end
if self.stopped then
-- BattleAnim_ClearOAM: unless the script asked to keep them, every object
-- goes at the end.
if not self.keepSprites then self.objects.oam = {} end
return false
end
return true
end
function Runner:oam() return self.objects.oam end
function Runner:done() return self.stopped end
AnimRunner.COMMANDS = C
AnimRunner.NUM_TILEDICT_ENTRIES = NUM_TILEDICT_ENTRIES
AnimRunner.MAX_ANIM_TILES = MAX_ANIM_TILES
AnimRunner.BATTLER_TILES = BATTLER_TILES
return AnimRunner
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
-- Which song a battle plays, and which one its win plays.
--
-- engine/battle/start_battle.asm PlayBattleMusic and engine/battle/core.asm
-- PlayVictoryMusic, as pure lookups: the caller hands over the trainer class,
-- the region and the time of day, and gets a song label back. Keeping it out
-- of World.lua is what lets a test assert the whole ladder -- Falkner's theme,
-- the Kanto split, the RIVAL2 cut-off -- without a window or an audio device.
--
-- Both routines start with `ld de, MUSIC_NONE / call PlayMusic`, i.e. the map
-- theme is stopped first; the port's Music.play replaces whatever is current,
-- so that step needs nothing here.
local BattleMusic = {}
-- data/trainers/leaders.asm. The two lists are contiguous in the ROM and
-- IsGymLeader reads BOTH (GymLeaders falls through into KantoGymLeaders),
-- while IsKantoGymLeader starts at the second -- so a Kanto leader is in each
-- list and a Johto one only in the first.
BattleMusic.KANTO_GYM_LEADERS = {
BROCK = true, MISTY = true, LT_SURGE = true, ERIKA = true,
JANINE = true, SABRINA = true, BLAINE = true, BLUE = true,
}
BattleMusic.JOHTO_GYM_LEADERS = {
FALKNER = true, WHITNEY = true, BUGSY = true, MORTY = true,
PRYCE = true, JASMINE = true, CHUCK = true, CLAIR = true,
WILL = true, BRUNO = true, KAREN = true, KOGA = true,
-- The list carries these two as well; PlayBattleMusic never reaches them
-- because it tests for them first, but PlayVictoryMusic's IsGymLeader call
-- does, which is why the Champion's defeat plays the gym jingle.
CHAMPION = true, RED = true,
}
-- IsGymLeader searches GymLeaders, which runs on into KantoGymLeaders.
function BattleMusic.isGymLeader(class)
if not class then return false end
return BattleMusic.JOHTO_GYM_LEADERS[class] == true
or BattleMusic.KANTO_GYM_LEADERS[class] == true
end
function BattleMusic.isKantoGymLeader(class)
return class ~= nil and BattleMusic.KANTO_GYM_LEADERS[class] == true
end
-- RegionCheck (engine/overworld/landmarks.asm) compares the map's landmark
-- against KANTO_LANDMARK; the Victory Road block above it counts as Johto
-- again, and so does the S.S. Aqua.
-- Indices into constants.lua's `landmarkOrder`.
BattleMusic.KANTO_LANDMARK = 46
BattleMusic.LANDMARK_VICTORY_ROAD = 87
BattleMusic.LANDMARK_FAST_SHIP = 94
function BattleMusic.isKanto(landmark)
local index = landmark or 0
if index == BattleMusic.LANDMARK_FAST_SHIP then return false end
if index < BattleMusic.KANTO_LANDMARK then return false end
return index < BattleMusic.LANDMARK_VICTORY_ROAD
end
-- The rival's theme becomes the Champion's from RIVAL2_2 onward (the Indigo
-- Plateau rematch): `cp RIVAL2_2_CHIKORITA / jr c, .done`, a comparison
-- against the MEMBER id inside the class.
BattleMusic.RIVAL2_CHAMPION_MEMBER = "RIVAL2_2_CHIKORITA"
-- opts:
-- class trainer class id ("FALKNER"), or nil for a wild battle
-- member trainer member id ("RIVAL2_1_TOTODILE") -- only RIVAL2 reads it
-- members the class's member list, to order `member` against
-- landmark the map's landmark index, for RegionCheck
-- daytime "MORN" | "DAY" | "NITE" | "DARK"
function BattleMusic.battleSong(opts)
opts = opts or {}
local class = opts.class
local kanto = BattleMusic.isKanto(opts.landmark)
if not class then
if kanto then return "Music_KantoWildBattle" end
-- Only NITE has its own wild theme; DARK (an unlit cave) is a palette
-- state, not a time of day, and keeps the day theme.
if opts.daytime == "NITE" then return "Music_JohtoWildBattleNight" end
return "Music_JohtoWildBattle"
end
if class == "CHAMPION" or class == "RED" then
return "Music_ChampionBattle"
end
-- The cart's own bug, kept: only the two GRUNT classes get the Rocket
-- theme, so an EXECUTIVE or SCIENTIST fights to the ordinary trainer song
-- (docs/bugs_and_glitches.md).
if class == "GRUNTM" or class == "GRUNTF" then
return "Music_RocketBattle"
end
if BattleMusic.isKantoGymLeader(class) then
return "Music_KantoGymBattle"
end
if BattleMusic.isGymLeader(class) then
return "Music_JohtoGymBattle"
end
if class == "RIVAL1" then return "Music_RivalBattle" end
if class == "RIVAL2" then
local cutoff, index
for i, id in ipairs(opts.members or {}) do
if id == BattleMusic.RIVAL2_CHAMPION_MEMBER then cutoff = i end
if id == opts.member then index = i end
end
if cutoff and index and index >= cutoff then
return "Music_ChampionBattle"
end
return "Music_RivalBattle"
end
if kanto then return "Music_KantoTrainerBattle" end
return "Music_JohtoTrainerBattle"
end
-- PlayVictoryMusic. A wild win is SILENT unless the player still has a
-- participant standing (or an Exp. Share, or Pay Day money) -- `wBattle
-- ParticipantsNotFainted` zero falls through to `.lost` with no PlayMusic at
-- all, which is why a battle won by a mon that fainted to recoil ends on the
-- map theme. Returns nil for that case.
function BattleMusic.victorySong(opts)
opts = opts or {}
if not opts.class then
if opts.participantsFainted then return nil end
return "Music_WildPokemonVictory"
end
if BattleMusic.isGymLeader(opts.class) then
return "Music_GymLeaderVictory"
end
return "Music_TrainerVictory"
end
return BattleMusic
+60
View File
@@ -0,0 +1,60 @@
-- ConvertBerriesToBerryJuice (engine/events/pokerus/pokerus.asm:124), the
-- first thing GivePokerusAndConvertBerries does on a battle WIN: gated on
-- ENGINE_REACHED_GOLDENROD like the Pokerus roll beside it, one byte under
-- `1 out_of 16` (16/256), then a walk down the party for a SHUCKLE holding
-- a BERRY. Only the FIRST match converts -- the routine returns the moment
-- it rewrites one item byte -- and nothing tells the player; the changed
-- held item is the whole event.
--
-- Shuckie (the Cianwood loaner) arrives holding a BERRY, which is the
-- intended payoff. The Pokerus half lives in src/core/gen2/Pokerus.lua;
-- both are called from the same battle-exit arm in
-- src/ui/gen2/BattleState.lua, conversion first, the way the asm orders
-- them.
local BerryJuice = {}
-- constants/engine_flags.asm index 21, same gate Pokerus.give reads.
BerryJuice.ENGINE_REACHED_GOLDENROD = 21
-- `cp 1 out_of 16` with out_of = `* $100 /`: a byte under 16 converts.
BerryJuice.ROLL_LIMIT = 16
function BerryJuice.random()
if love and love.math and love.math.random then
return love.math.random(0, 255)
end
return math.random(0, 255)
end
-- The walk itself. `opts.random` is a function of no arguments returning
-- 0..255 (the Pokerus convention), `opts.reachedGoldenrod` the engine flag.
-- Returns the party slot that converted, or nil.
function BerryJuice.convert(party, opts)
opts = opts or {}
if not opts.reachedGoldenrod then return nil end
local roll = (opts.random or BerryJuice.random)()
if roll >= BerryJuice.ROLL_LIMIT then return nil end
for index, mon in ipairs(party or {}) do
if mon.species == "SHUCKLE" and mon.item == "BERRY" then
mon.item = "BERRY_JUICE"
return index
end
end
return nil
end
-- The save-facing wrapper, mirroring Pokerus.giveAfterBattle's shape so the
-- battle exit calls the two the same way.
function BerryJuice.convertAfterBattle(save, party, opts)
if type(save) ~= "table" then return nil end
opts = opts or {}
local flags = save.engineFlags or {}
return BerryJuice.convert(party or save.party or {}, {
random = opts.random,
reachedGoldenrod =
flags[BerryJuice.ENGINE_REACHED_GOLDENROD] == true,
})
end
return BerryJuice
File diff suppressed because it is too large Load Diff
+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
+328
View File
@@ -0,0 +1,328 @@
-- Gen 2 damage.
--
-- Ported from engine/battle/effect_commands.asm, in the order the cart's move
-- sequence runs them: damagestats -> damagecalc -> stab -> damagevariation.
--
-- What differs from Gen 1 (src/battle/Damage.lua), and why this is its own
-- module rather than a flag on that one:
-- * Special is split into Special Attack and Special Defense, so a special
-- move reads the attacker's SpA against the defender's SpD instead of both
-- sides' single `special`.
-- * Critical hits are a *chance ladder* (data/battle/critical_hit_chances.asm
-- 1/15, 1/8, 1/4, 1/3, 1/2) indexed by a "critical level" that Focus
-- Energy, a high-crit move, Scope Lens, and the Lucky Punch / Stick raise.
-- Gen 1 instead derived the chance from base Speed.
-- * A critical hit is a flat x2 and, unlike Gen 1, ignores the attacker's
-- *negative* stat stages rather than all stages.
-- * Type-boost held items (Charcoal, Mystic Water, ...) multiply before the
-- crit, and Steel and Dark exist in the matchup table.
--
-- Whether a move is physical or special is still decided by its *type*, not
-- per-move as in Gen 4: type ids below FIRE are physical. type_chart.lua's
-- records carry that as `category`.
local Damage = {}
-- data/battle/critical_hit_chances.asm, as "1 in N".
Damage.CRITICAL_CHANCES = { [0] = 15, 8, 4, 3, 2, 2, 2 }
-- Gen 2's damage spread: 85% to 100% inclusive.
Damage.MIN_VARIATION = 85
Damage.MAX_VARIATION = 100
-- The cart caps a single hit at 999 (DAMAGE_CAP + MIN_DAMAGE in
-- BattleCommand_DamageCalc).
Damage.MAX_DAMAGE = 999
-- BattleCommand_DamageCalc's tail (engine/battle/effect_commands.asm) caps the
-- computed damage at DAMAGE_CAP (997) and then adds MIN_DAMAGE (2) back, so
-- every damaging hit leaves DamageCalc worth at least 2 -- which is what keeps
-- a resisted hit from flooring to zero once the type matchup halves it.
Damage.MIN_DAMAGE = 2
-- Stat stage multipliers (numerator, denominator), -6..+6. Same table as
-- Gen 1; a critical hit skips only the negative half for the attacker.
local STAGE = {
[-6] = { 25, 100 }, [-5] = { 28, 100 }, [-4] = { 33, 100 },
[-3] = { 40, 100 }, [-2] = { 50, 100 }, [-1] = { 66, 100 },
[0] = { 1, 1 },
[1] = { 15, 10 }, [2] = { 2, 1 }, [3] = { 25, 10 },
[4] = { 3, 1 }, [5] = { 35, 10 }, [6] = { 4, 1 },
}
function Damage.stageMultiplier(stage)
local entry = STAGE[math.max(-6, math.min(6, stage or 0))]
return entry[1], entry[2]
end
-- Apply a stat stage, flooring like the cart's Multiply/Divide pair, and never
-- letting a stat reach 0 (a 0 defence would divide by zero).
function Damage.applyStage(value, stage)
local numerator, denominator = Damage.stageMultiplier(stage)
local out = math.floor(value * numerator / denominator)
return math.max(1, out)
end
-- Is this move physical? `types` is type_chart.lua's `types` table.
function Damage.isPhysical(moveType, types)
local record = types and types[moveType]
if record and record.category then return record.category == "physical" end
-- Without the table, fall back to the Gen 1/2 boundary: the physical block
-- runs NORMAL..GROUND, and the special block starts at FIRE.
local PHYSICAL = {
NORMAL = true, FIGHTING = true, FLYING = true, POISON = true,
GROUND = true, ROCK = true, BUG = true, GHOST = true, STEEL = true,
}
return PHYSICAL[moveType] == true
end
-- The 1-in-N chance for a critical level.
function Damage.criticalChance(level)
local capped = math.max(0, math.min(6, level or 0))
return Damage.CRITICAL_CHANCES[capped]
end
-- BattleCommand_Critical, as a level rather than a roll:
-- +1 Focus Energy, +2 a high-crit move, +1 Scope Lens,
-- +2 Lucky Punch on Chansey / Stick on Farfetch'd.
function Damage.criticalLevel(opts)
local level = 0
if opts.focusEnergy then level = level + 1 end
if opts.highCritMove then level = level + 2 end
if opts.scopeLens then level = level + 1 end
if opts.speciesItemBonus then level = level + 2 end
return math.min(6, level)
end
-- Roll a critical hit. `random(n)` must return 0..n-1 (the cart compares a
-- BattleRandom byte against the chance), and defaults to love/math random.
function Damage.rollCritical(criticalLevel, random)
local chance = Damage.criticalChance(criticalLevel)
local roll
if random then
roll = random(chance)
elseif love and love.math then
roll = love.math.random(chance) - 1
else
roll = math.random(chance) - 1
end
return roll == 0
end
-- The x10 type multiplier of a move against a defender, applying each matchup
-- row separately and flooring in between -- the same rule Gen 1 follows, which
-- is why a dual type can land on 4x or 0.25x.
function Damage.typeMultiplier(moveType, defenderTypes, matchups)
local multiplier = 10
for _, row in ipairs(matchups or {}) do
if row.attacker == moveType then
for _, defenderType in ipairs(defenderTypes or {}) do
if row.defender == defenderType then
multiplier = math.floor(multiplier * row.multiplier / 10)
break
end
end
end
end
return multiplier
end
-- The core formula (BattleCommand_DamageCalc):
--
-- (((2 * Level / 5 + 2) * Power * Attack / Defense) / 50)
--
-- every step floored, defence clamped to at least 1.
function Damage.base(level, power, attack, defense)
if (power or 0) <= 0 then return 0 end
defense = math.max(1, defense or 1)
local value = math.floor(level * 2 / 5) + 2
value = value * power
value = value * attack
value = math.floor(value / defense)
value = math.floor(value / 50)
return value
end
-- Every `info` table Damage.calc returns carries BOTH generations' names for
-- the same two facts: Gen 2 calls them `critical` and `effectiveness`, Gen 1
-- (src/battle/Damage.lua) calls them `crit` and `typeMult`. The battle.damage
-- hook hands this table to whatever wrapped it, and a mod written against Red
-- must be able to read what it wrapped.
local function withGen1Names(info)
info.crit = info.critical
info.typeMult = info.effectiveness
return info
end
-- opts:
-- level, power, moveType
-- attacker { attack, specialAttack, types, stages = { attack =, ... } }
-- defender { defense, specialDefense, types, stages }
-- types, matchups -- type_chart.lua's `types` / `matchups`
-- critical -- boolean (roll it with rollCritical first)
-- itemBoostPercent -- type-boost held item, e.g. 10 for Charcoal
-- weatherPercent -- DoWeatherModifiers in tenths (15 / 5 / nil),
-- applied ahead of the badge boost and STAB
-- badgeTypeBoost -- DoBadgeTypeBoosts: the player owns the badge
-- matching the move's type, +1/8 before STAB
-- variation -- 85..100; omit to roll
-- random(n) -- 0..n-1, for the variation roll
-- screen -- Reflect/Light Screen active on the defender
-- defenseHalved -- EFFECT_SELFDESTRUCT's srl c
--
-- Returns damage, info where info carries the pieces a battle message needs:
-- effectiveness (x10), critical, physical, variation.
function Damage.calc(opts)
local physical = Damage.isPhysical(opts.moveType, opts.types)
local attacker = opts.attacker or {}
local defender = opts.defender or {}
local stagesA = attacker.stages or {}
local stagesD = defender.stages or {}
local rawAttack = physical and (attacker.attack or 1)
or (attacker.specialAttack or attacker.special or 1)
local rawDefense = physical and (defender.defense or 1)
or (defender.specialDefense or defender.special or 1)
local stageA = physical and (stagesA.attack or 0)
or (stagesA.specialAttack or 0)
local stageD = physical and (stagesD.defense or 0)
or (stagesD.specialDefense or 0)
-- A critical hit ignores stat changes that would *lower* the damage: the
-- attacker's negative stages and the defender's positive ones.
if opts.critical then
if stageA < 0 then stageA = 0 end
if stageD > 0 then stageD = 0 end
end
local attack = Damage.applyStage(rawAttack, stageA)
local defense = Damage.applyStage(rawDefense, stageD)
-- Reflect and Light Screen double the matching defence, and are the one
-- multiplier a critical hit also ignores.
if opts.screen and not opts.critical then
defense = defense * 2
end
-- BattleCommand_DamageCalc (effect_commands.asm:2905-2913): Selfdestruct and
-- Explosion halve the defence, never below 1.
if opts.defenseHalved then defense = math.max(1, math.floor(defense / 2)) end
if (opts.power or 0) <= 0 then
return 0, withGen1Names({ effectiveness = 10, critical = false,
physical = physical })
end
local damage = Damage.base(opts.level or 1, opts.power or 0, attack, defense)
-- Type-boost held items multiply before the crit (.NextItem / .DoneItem).
if opts.itemBoostPercent and opts.itemBoostPercent > 0 then
damage = math.floor(damage * (100 + opts.itemBoostPercent) / 100)
end
if opts.critical then damage = damage * 2 end
-- BattleCommand_DamageCalc's tail: cap at DAMAGE_CAP, then add MIN_DAMAGE
-- back, so even a hit whose stat math floored to nothing leaves with 2.
damage = math.min(damage, Damage.MAX_DAMAGE - Damage.MIN_DAMAGE)
+ Damage.MIN_DAMAGE
-- DoWeatherModifiers (engine/battle/misc.asm:102-140), farcalled by
-- BattleCommand_Stab as its very FIRST act (effect_commands.asm:1254): it
-- sits ahead of the badge boost, the STAB x1.5, the type rows and
-- DamageVariation, so every later step floors on top of it. The table's
-- values are tenths (weather_modifiers.asm: MORE_EFFECTIVE 15,
-- NOT_VERY_EFFECTIVE 05), and .ApplyModifier's zero-quotient arm forces the
-- result back to 1, so a weather-halved hit never falls to nothing.
if opts.weatherPercent and opts.weatherPercent ~= 10 then
damage = math.max(1, math.floor(damage * opts.weatherPercent / 10))
end
-- DoBadgeTypeBoosts (engine/battle/misc.asm:146), farcalled from
-- BattleCommand_Stab ahead of the STAB multiply: a matching owned badge
-- adds an eighth of the running damage, at least 1, on the player's turn.
if opts.badgeTypeBoost then
damage = damage + math.max(1, math.floor(damage / 8))
end
-- STAB, then each type row. BattleCommand_Stab does STAB first, so a
-- resisted same-type move floors after the x1.5.
local stab = false
for _, attackerType in ipairs(attacker.types or {}) do
if attackerType == opts.moveType then stab = true break end
end
if stab then damage = math.floor(damage * 15 / 10) end
-- Each matchup row multiplies the running damage separately, the way
-- BattleCommand_Stab's .TypesLoop does -- and its zero-quotient check forces
-- the damage back to 1 whenever a non-immune row floors it to nothing, so a
-- resisted hit that lands always deals at least 1 HP.
local effectiveness = Damage.typeMultiplier(
opts.moveType, defender.types, opts.matchups)
for _, row in ipairs(opts.matchups or {}) do
if row.attacker == opts.moveType then
for _, defenderType in ipairs(defender.types or {}) do
if row.defender == defenderType then
damage = math.floor(damage * row.multiplier / 10)
if damage == 0 and row.multiplier > 0 then damage = 1 end
break
end
end
end
end
if effectiveness <= 0 or damage <= 0 then
return 0, withGen1Names({
effectiveness = effectiveness, critical = opts.critical or false,
physical = physical, stab = stab,
})
end
-- Damage variation last, and only when the running damage is 2 or more
-- (BattleCommand_DamageVariation returns early below that).
local variation = opts.variation
if not variation then
if opts.random then
variation = Damage.MIN_VARIATION
+ opts.random(Damage.MAX_VARIATION - Damage.MIN_VARIATION + 1)
elseif love and love.math then
variation = love.math.random(Damage.MIN_VARIATION, Damage.MAX_VARIATION)
else
variation = math.random(Damage.MIN_VARIATION, Damage.MAX_VARIATION)
end
end
if damage >= 2 then
damage = math.floor(damage * variation / 100)
end
damage = math.max(1, math.min(Damage.MAX_DAMAGE, damage))
return damage, withGen1Names({
effectiveness = effectiveness,
critical = opts.critical or false,
physical = physical,
stab = stab,
variation = variation,
})
end
-- Accuracy check. Gen 2 rolls one byte against accuracy scaled by the
-- attacker's accuracy stage and the defender's evasion stage; accuracy of 0 in
-- the data means "never misses" (Swift and friends).
function Damage.rollHit(accuracy, accuracyStage, evasionStage, random)
if not accuracy or accuracy <= 0 then return true end
local numerator, denominator = Damage.stageMultiplier(accuracyStage or 0)
local value = math.floor(accuracy * numerator / denominator)
numerator, denominator = Damage.stageMultiplier(-(evasionStage or 0))
value = math.floor(value * numerator / denominator)
value = math.max(1, math.min(100, value))
local roll
if random then
roll = random(100)
elseif love and love.math then
roll = love.math.random(100) - 1
else
roll = math.random(100) - 1
end
return roll < value
end
return Damage
+510
View File
@@ -0,0 +1,510 @@
-- Gen 2 move effects, as data plus the small amount of arithmetic each one
-- needs. Ported from engine/battle/effect_commands.asm; the battle engine
-- (src/battle/gen2/Battle.lua) owns the turn loop and calls in here for what a
-- move does beyond "roll damage, maybe inflict a status".
--
-- Everything is keyed by the move's *effect*, the same byte data/moves/moves.asm
-- stores, so a modded move that copies an effect inherits its behaviour -- and
-- an effect this table does not name still lands as an ordinary hit rather than
-- silently doing the wrong thing.
--
-- No love calls and no engine state: every function takes what it needs and
-- returns a value or a small table, which is what lets the tests drive them
-- directly.
local Effects = {}
-- --------------------------------------------------------------- stat stages
--
-- Stat changes come in four shapes and the effect name says which:
-- *_UP / *_UP_2 raise the user, by one stage or two
-- *_DOWN / *_DOWN_2 lower the target
-- *_UP_HIT raise the user after a damaging hit
-- *_DOWN_HIT lower the target after a damaging hit
-- StatUpMessage / StatDownMessage are the same either way, so the direction
-- and the target are the only things worth tabulating.
-- { stat, stages, target } where target is "self" or "foe".
Effects.STAT_CHANGES = {
EFFECT_ATTACK_UP = { "attack", 1, "self" },
EFFECT_DEFENSE_UP = { "defense", 1, "self" },
EFFECT_SP_ATK_UP = { "specialAttack", 1, "self" },
EFFECT_EVASION_UP = { "evasion", 1, "self" },
EFFECT_ATTACK_UP_2 = { "attack", 2, "self" },
EFFECT_DEFENSE_UP_2 = { "defense", 2, "self" },
EFFECT_SPEED_UP_2 = { "speed", 2, "self" },
EFFECT_SP_DEF_UP_2 = { "specialDefense", 2, "self" },
-- Defense Curl also arms Rollout, which Battle tracks separately.
EFFECT_DEFENSE_CURL = { "defense", 1, "self" },
EFFECT_ATTACK_DOWN = { "attack", -1, "foe" },
EFFECT_DEFENSE_DOWN = { "defense", -1, "foe" },
EFFECT_SPEED_DOWN = { "speed", -1, "foe" },
EFFECT_ACCURACY_DOWN = { "accuracy", -1, "foe" },
EFFECT_EVASION_DOWN = { "evasion", -1, "foe" },
EFFECT_ATTACK_DOWN_2 = { "attack", -2, "foe" },
EFFECT_DEFENSE_DOWN_2 = { "defense", -2, "foe" },
EFFECT_SPEED_DOWN_2 = { "speed", -2, "foe" },
}
-- The secondary versions, rolled against the move's effect chance after a hit.
Effects.STAT_CHANGES_ON_HIT = {
EFFECT_ATTACK_UP_HIT = { "attack", 1, "self" },
EFFECT_DEFENSE_UP_HIT = { "defense", 1, "self" },
EFFECT_ATTACK_DOWN_HIT = { "attack", -1, "foe" },
EFFECT_DEFENSE_DOWN_HIT = { "defense", -1, "foe" },
EFFECT_SPEED_DOWN_HIT = { "speed", -1, "foe" },
EFFECT_ACCURACY_DOWN_HIT = { "accuracy", -1, "foe" },
EFFECT_SP_DEF_DOWN_HIT = { "specialDefense", -1, "foe" },
}
-- Ancient Power raises every one of the user's stats at once.
Effects.ALL_UP_STATS = {
"attack", "defense", "speed", "specialAttack", "specialDefense",
}
Effects.STAT_NAMES = {
attack = "ATTACK", defense = "DEFENSE", speed = "SPEED",
specialAttack = "SPCL.ATK", specialDefense = "SPCL.DEF",
accuracy = "ACCURACY", evasion = "EVASION",
}
-- Stages clamp at ±6 (BattleCommand_StatUp's .CantRaise / .CantLower).
Effects.MAX_STAGE = 6
-- Applies a change and says what happened, so the caller can emit the cart's
-- own message: nil when the stage was already at the cap.
function Effects.applyStage(stages, stat, delta)
if not (stages and stat) then return nil end
local current = stages[stat] or 0
local wanted = current + delta
if wanted > Effects.MAX_STAGE then wanted = Effects.MAX_STAGE end
if wanted < -Effects.MAX_STAGE then wanted = -Effects.MAX_STAGE end
if wanted == current then return nil end
stages[stat] = wanted
return wanted - current
end
-- BattleCommand_StatUpMessage / StatDownMessage: one stage is "rose"/"fell",
-- two are "sharply rose" / "sharply fell".
function Effects.stageMessage(name, stat, applied)
local label = Effects.STAT_NAMES[stat] or stat
local sharply = math.abs(applied) >= 2 and "sharply " or ""
local verb = applied > 0 and "rose" or "fell"
return ("%s's %s %s%s!"):format(name, label, sharply, verb)
end
-- ------------------------------------------------------------------ hit count
--
-- BattleCommand_CheckHit's multi-hit roll: 2 and 3 hits are 3/8 each, 4 and 5
-- are 1/8 each, which is what the `and 3` on a 0-3 roll plus the two-step
-- fallthrough in .DetermineNumberOfHits produces.
function Effects.multiHitCount(random)
local roll = random and random(4) or 0
if roll < 2 then return roll + 2 end
-- Hits 4 and 5 take a second roll, so each ends up half as likely.
local second = random and random(2) or 0
return second + 4
end
Effects.HIT_COUNTS = {
EFFECT_DOUBLE_HIT = 2,
-- Triple Kick stops early if a hit misses; Battle rolls that per hit.
EFFECT_TRIPLE_KICK = 3,
}
function Effects.hitCount(effect, random)
if effect == "EFFECT_MULTI_HIT" or effect == "EFFECT_POISON_MULTI_HIT" then
return Effects.multiHitCount(random)
end
return Effects.HIT_COUNTS[effect] or 1
end
-- Triple Kick's power climbs 10/20/30 across its three kicks
-- (BattleCommand_TripleKick).
function Effects.tripleKickPower(base, hit)
return (base or 10) * hit
end
-- ----------------------------------------------------------- recoil and drain
-- BattleCommand_Recoil: a quarter of the damage dealt, minimum 1.
function Effects.recoilDamage(damageDealt)
return math.max(1, math.floor((damageDealt or 0) / 4))
end
-- BattleCommand_DrainTarget: half the damage dealt, minimum 1.
function Effects.drainAmount(damageDealt)
return math.max(1, math.floor((damageDealt or 0) / 2))
end
Effects.DRAIN = {
EFFECT_LEECH_HIT = true,
EFFECT_DREAM_EATER = true,
}
-- --------------------------------------------------------------- two-turn
--
-- The charge moves all share BattleCommand_Charge: turn one prints a line and
-- stores the move, turn two attacks. Fly and Dig also make the user
-- untargetable in between, which is the `semi-invulnerable` flag here.
Effects.CHARGE = {
EFFECT_RAZOR_WIND = { text = "%s made a whirlwind!" },
EFFECT_SOLARBEAM = { text = "%s took in sunlight!" },
EFFECT_SKULL_BASH = { text = "%s lowered its head!" },
EFFECT_SKY_ATTACK = { text = "%s is glowing!" },
EFFECT_FLY = { text = "%s flew up high!", vanish = true },
}
-- CheckHit's .FlyDigMoves (effect_commands.asm:1713-1746): a vanished target
-- is not a flat miss, four moves reach it in the air and three underground.
Effects.FLY_DIG_EXCEPTIONS = {
FLY = { GUST = true, WHIRLWIND = true, THUNDER = true, TWISTER = true },
DIG = { EARTHQUAKE = true, FISSURE = true, MAGNITUDE = true },
}
-- Keyed by the charge move the target is partway through, which is what the
-- port carries in place of SUBSTATUS_FLYING / SUBSTATUS_UNDERGROUND.
function Effects.hitsVanished(chargeMove, moveId)
local reaches = Effects.FLY_DIG_EXCEPTIONS[chargeMove]
return (reaches and reaches[moveId]) and true or false
end
-- --------------------------------------------------------------- fixed damage
-- BattleCommand_LevelDamage / SuperFang / Psywave, all of which skip the
-- damage formula entirely.
function Effects.fixedDamage(effect, attacker, defender, random)
if effect == "EFFECT_LEVEL_DAMAGE" then
return math.max(1, attacker.level or 1)
end
if effect == "EFFECT_SUPER_FANG" then
return math.max(1, math.floor((defender.hp or 1) / 2))
end
if effect == "EFFECT_PSYWAVE" then
-- 1..(level * 1.5), rerolled until it is in range; one roll is enough here.
local ceiling = math.max(1, math.floor((attacker.level or 1) * 3 / 2))
return math.max(1, (random and random(ceiling) or 0) + 1)
end
return nil
end
-- --------------------------------------------------------------- Substitute
-- BattleCommand_Substitute: a quarter of max HP, which is also what the user
-- pays. Refuses when the user has that much HP or less.
function Effects.substituteCost(maxHp)
return math.max(1, math.floor((maxHp or 1) / 4))
end
-- ------------------------------------------------------------ counter moves
-- Counter answers physical damage, Mirror Coat special, both at double and
-- both only when the foe hit the user this turn (BattleCommand_Counter).
Effects.COUNTER = {
EFFECT_COUNTER = "physical",
EFFECT_MIRROR_COAT = "special",
}
function Effects.counterDamage(taken)
return math.max(1, (taken or 0) * 2)
end
-- ------------------------------------------------------ rollout / fury cutter
-- Both double their power per consecutive use, Rollout for five turns and Fury
-- Cutter until it misses; the cart caps the doubling at 5 steps either way.
Effects.RAMPING = {
EFFECT_ROLLOUT = 5,
EFFECT_FURY_CUTTER = 5,
}
function Effects.rampedPower(base, count, curled)
local steps = math.min(math.max(count or 0, 0), 4)
local power = (base or 1) * 2 ^ steps
-- Defense Curl doubles Rollout again (BattleCommand_RolloutPower).
if curled then power = power * 2 end
return math.floor(power)
end
-- ----------------------------------------------------------------- magnitude
--
-- data/moves/magnitude_power.asm, one row per magnitude: { chance, power,
-- magnitude number }. The chance column is assembled through `percent`
-- (`* $ff / 100`, macros/data.asm:23), so `5 percent + 1` is 13 and
-- `100 percent` is 255 -- the thresholds below are those bytes, not the
-- percentages they were written as.
Effects.MAGNITUDE_POWER = {
{ 13, 10, 4 },
{ 38, 30, 5 },
{ 89, 50, 6 },
{ 166, 70, 7 },
{ 217, 90, 8 },
{ 242, 110, 9 },
{ 255, 150, 10 },
}
-- BattleCommand_GetMagnitude (engine/battle/move_effects/magnitude.asm): ONE
-- random byte walks the table and the first row whose threshold is not below
-- it wins (`ld a, [hli] / cp b / jr nc`). The row's power goes into d, which
-- is what damagecalc reads as the move's power -- data/moves/moves.asm stores
-- MAGNITUDE at power 1 precisely because this overwrites it. Returns the
-- power and the magnitude number the text prints.
function Effects.magnitudePower(random)
local roll = random and random(256) or 0
for _, row in ipairs(Effects.MAGNITUDE_POWER) do
if row[1] >= roll then return row[2], row[3] end
end
local last = Effects.MAGNITUDE_POWER[#Effects.MAGNITUDE_POWER]
return last[2], last[3]
end
-- ------------------------------------------------------------------- weather
--
-- BattleCommand_StartRain / StartSun / StartSandstorm all set wWeatherCount to
-- 5, which HandleWeather decrements at the end of every turn; the turn it
-- reaches zero the weather ends. data/battle/weather_modifiers.asm is the
-- whole of what weather does to damage.
Effects.WEATHER = {
EFFECT_RAIN_DANCE = "rain",
EFFECT_SUNNY_DAY = "sun",
EFFECT_SANDSTORM = "sandstorm",
}
Effects.WEATHER_TURNS = 5
Effects.WEATHER_START_TEXT = {
rain = "It started to rain!",
sun = "The sunlight got bright!",
sandstorm = "A sandstorm brewed!",
}
Effects.WEATHER_TURN_TEXT = {
rain = "Rain continues to fall.",
sun = "The sunlight is strong.",
sandstorm = "The sandstorm rages.",
}
Effects.WEATHER_END_TEXT = {
rain = "The rain stopped.",
sun = "The sunlight faded.",
sandstorm = "The sandstorm subsided.",
}
-- data/battle/weather_modifiers.asm pairs each weather with MORE_EFFECTIVE or
-- NOT_VERY_EFFECTIVE, and those are 15 and 05 in tenths
-- (constants/battle_constants.asm:22, :24) -- MORE_EFFECTIVE is x1.5, NOT the
-- type chart's x2, which is SUPER_EFFECTIVE (20). Gen 2's weather boost is a
-- half again, and only the type chart doubles.
Effects.WEATHER_TYPE_MODIFIERS = {
rain = { WATER = 1.5, FIRE = 0.5 },
sun = { FIRE = 1.5, WATER = 0.5 },
}
-- The one move whose EFFECT rather than type is modified: Solarbeam in rain.
Effects.WEATHER_MOVE_MODIFIERS = {
rain = { EFFECT_SOLARBEAM = 0.5 },
}
function Effects.weatherModifier(weather, moveType, effect)
if not weather then return 1 end
local byType = Effects.WEATHER_TYPE_MODIFIERS[weather]
if byType and byType[moveType] then return byType[moveType] end
local byMove = Effects.WEATHER_MOVE_MODIFIERS[weather]
if byMove and byMove[effect] then return byMove[effect] end
return 1
end
-- HandleWeather's .SandstormDamage: an eighth of max HP, and Rock, Ground and
-- Steel are immune. A mon underground (Dig) is skipped too.
Effects.SANDSTORM_IMMUNE = { ROCK = true, GROUND = true, STEEL = true }
function Effects.sandstormDamage(maxHp)
return math.max(1, math.floor((maxHp or 8) / 8))
end
function Effects.sandstormHits(types)
for _, type_ in ipairs(types or {}) do
if Effects.SANDSTORM_IMMUNE[type_] then return false end
end
return true
end
-- Morning Sun / Synthesis / Moonlight heal a HALF normally, and the weather
-- shifts that one step either way: x2 in sun, /2 in rain or sandstorm
-- (BattleCommand_Heal's .Weather block walks a multiplier index).
Effects.SUN_HEAL = {
EFFECT_MORNING_SUN = true,
EFFECT_SYNTHESIS = true,
EFFECT_MOONLIGHT = true,
}
function Effects.weatherHealFraction(weather)
if weather == "sun" then return 2 / 3 end
if weather == "rain" or weather == "sandstorm" then return 1 / 4 end
return 1 / 2
end
-- ---------------------------------------------------------------- Perish Song
--
-- BattleCommand_PerishSong sets the counter to 4 on BOTH sides; it ticks down
-- at the end of every turn and the mon faints when it reaches 0.
Effects.PERISH_TURNS = 4
-- -------------------------------------------------------------------- Encore
--
-- 3-6 turns (`and $3` plus three increments), and the target is locked into
-- the move it last used. Encore, Mirror Move and Struggle cannot be encored,
-- and neither can a move with no PP left.
Effects.ENCORE_BLOCKED = {
ENCORE = true, MIRROR_MOVE = true, STRUGGLE = true,
}
function Effects.encoreTurns(random)
return (random and random(4) or 0) + 3
end
-- ------------------------------------------------------------------- Disable
--
-- The count is a packed byte: the low nybble is the number of turns (1-8, the
-- `and 7` retried until nonzero, then incremented) and the high nybble is the
-- move slot plus one. Only the turn count matters to the port, but the shape
-- is what says a slot of 0 means "nothing disabled".
function Effects.disableTurns(random)
local roll = 0
for _ = 1, 8 do
roll = (random and random(8) or 1) % 8
if roll ~= 0 then break end
end
if roll == 0 then roll = 1 end
return roll + 1
end
-- --------------------------------------------------------- Protect and Endure
--
-- ProtectChance halves the success chance for every CONSECUTIVE use: the
-- threshold starts at $ff and is shifted right once per use, so use n
-- succeeds with probability (256 >> n) / 256. Once the shift reaches zero the
-- move always fails, which is five uses.
function Effects.protectChance(consecutive)
local threshold = 0xff
for _ = 1, (consecutive or 0) do
threshold = math.floor(threshold / 2)
if threshold == 0 then return 0 end
end
return threshold
end
-- The roll is a non-zero byte, decremented, and the move succeeds when it is
-- BELOW the threshold.
function Effects.protectSucceeds(consecutive, random)
local threshold = Effects.protectChance(consecutive)
if threshold == 0 then return false end
local roll = (random and random(255) or 0) + 1
return (roll - 1) < threshold
end
-- ---------------------------------------------------------------------- Bide
--
-- BattleCommand_UnleashEnergy stores for 2 or 3 turns (`and 1` plus two
-- increments) and BattleCommand_StoreEnergy pays back DOUBLE everything the
-- user took while storing, capped at the 16-bit maximum.
function Effects.bideTurns(random)
return (random and random(2) or 0) + 2
end
function Effects.bideDamage(stored)
return math.min(0xffff, (stored or 0) * 2)
end
-- ---------------------------------------------------------------------- Rage
--
-- SUBSTATUS_RAGE: while it is set, every hit the user takes raises its Attack
-- one stage. It is cleared by using any other move.
-- ---------------------------------------------------------------- Future Sight
--
-- Four turns: the damage is rolled NOW, stored, and lands when the counter
-- reaches one. The move itself does nothing on the turn it is used.
Effects.FUTURE_SIGHT_TURNS = 4
-- ---------------------------------------------------------------------- OHKO
--
-- BattleCommand_OHKO fails outright when the target is the higher level; when
-- it is not, the move's accuracy becomes acc + 2 * (level difference), capped
-- at 255, and a hit sets damage to $ffff.
function Effects.ohkoAccuracy(baseAccuracy, userLevel, targetLevel)
if (targetLevel or 1) > (userLevel or 1) then return nil end
local bonus = ((userLevel or 1) - (targetLevel or 1)) * 2
return math.min(255, (baseAccuracy or 0) + bonus)
end
-- ------------------------------------------------------------------- Beat Up
--
-- One hit per party member that is alive and free of any major status, each
-- swinging with that member's own base Attack and level against the target's
-- base Defense. The move fails outright when nobody qualifies.
function Effects.beatUpParty(party, activeIndex)
local hits = {}
for index, mon in ipairs(party or {}) do
local healthy = (mon.hp or 0) > 0
-- The ACTIVE mon is checked against its battle status rather than its
-- party record, which is the same thing here.
local clean = not mon.status or (index == activeIndex and not mon.status)
if healthy and clean then
hits[#hits + 1] = { index = index, mon = mon }
end
end
return hits
end
-- --------------------------------------------------------------- Baton Pass
--
-- ResetBatonPassStatus: what does NOT survive the switch. Everything else --
-- the stat stages, Substitute, Leech Seed, Perish Song, the confusion counter
-- -- goes with the incoming mon, which is the whole point of the move.
--
-- `preTransform` is deliberately NOT on this list even though `transformed` is:
-- it is the passer's own identity waiting to be put back, and the drops run
-- BEFORE Battle:clearVolatile, which is what puts it back and clears both keys
-- (Battle:untransform). Dropping it here would strand a passing DITTO as a
-- permanent copy instead.
Effects.BATON_PASS_DROPS = {
"nightmare", "disable", "disableTurns", "attract", "transformed",
"encore", "encoreTurns", "lastMove",
}
-- ------------------------------------------------------------------ Metronome
--
-- data/moves/metronome_exception_moves.asm: Metronome cannot pick these, and
-- it also never picks a move the user already knows.
Effects.METRONOME_EXCEPTIONS = {
METRONOME = true, STRUGGLE = true, SKETCH = true, MIMIC = true,
COUNTER = true, MIRROR_COAT = true, PROTECT = true, DETECT = true,
ENDURE = true, DESTINY_BOND = true, SLEEP_TALK = true, THIEF = true,
}
-- Picks a move id uniformly out of `moveOrder`, rerolling on an excepted move
-- or one the user already has -- the same reject loop .GetMove runs.
function Effects.metronomePick(moveOrder, known, random)
local count = #(moveOrder or {})
if count == 0 then return nil end
local owned = {}
for _, move in ipairs(known or {}) do owned[move.id or move] = true end
for _ = 1, 64 do
local pick = moveOrder[(random and random(count) or 0) + 1]
if pick and not Effects.METRONOME_EXCEPTIONS[pick] and not owned[pick] then
return pick
end
end
return nil
end
-- ---------------------------------------------------------------- Mirror Move
--
-- Copies the OPPONENT's last move, and fails when there is none or when the
-- user already knows it -- CheckUserMove returns "found", and Mirror Move
-- takes the .failed branch on a hit.
return Effects
+182
View File
@@ -0,0 +1,182 @@
-- Gen 2 wild encounters (engine/overworld/wildmons.asm).
--
-- The Gen 2 mechanic Gen 1 does not have: a grass table holds three separate
-- seven-slot lists, one per time of day, plus a per-time encounter *rate*. So
-- the same patch of grass on Route 29 gives Pidgey in the morning and Hoothoot
-- at night, and the clock that decides which is the same one that decides the
-- palette -- see src/world/gen2/Palettes.lua.
--
-- Slot probabilities are Gen 2's ProbabilityTable (data/wild/probabilities.asm):
-- 30, 30, 20, 10, 5, 4, 1 percent across the seven slots, cumulative.
local Encounter = {}
-- data/wild/probabilities.asm, cumulative out of 100.
Encounter.GRASS_SLOT_CHANCES = { 30, 60, 80, 90, 95, 99, 100 }
-- Water has three slots: 60, 30, 10.
Encounter.WATER_SLOT_CHANCES = { 60, 90, 100 }
local function roll(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
-- Which slot a 0..99 roll lands in.
local function slotFor(chances, value)
for index, cumulative in ipairs(chances) do
if value < cumulative then return index end
end
return #chances
end
-- Does a step in grass start a battle? The map's rate is out of 256
-- (`db 2 percent`), and the cart compares one random byte against it.
function Encounter.triggers(rate, random)
if not rate or rate <= 0 then return false end
return roll(random, 256) < rate
end
-- The grass encounter for a map at a time of day, or nil when that map has
-- none. `daytime` is "MORN"/"DAY"/"NITE"/"DARK"; DARK reuses the night list,
-- since the cart only stores three (wildmons.asm masks the palette daytime down
-- to three when indexing).
function Encounter.grassSlot(encounters, mapId, daytime, random)
local entry = encounters and encounters.grass and encounters.grass[mapId]
if not entry then return nil end
local key = (daytime == "DARK") and "NITE" or (daytime or "DAY")
local slots = entry.slots and (entry.slots[key] or entry.slots.DAY)
if not slots then return nil end
local index = slotFor(Encounter.GRASS_SLOT_CHANCES, roll(random, 100))
local slot = slots[index]
if not slot or not slot.species then return nil end
return { species = slot.species, level = slot.level, slot = index }
end
function Encounter.grassRate(encounters, mapId, daytime)
local entry = encounters and encounters.grass and encounters.grass[mapId]
if not entry then return 0 end
local key = (daytime == "DARK") and "NITE" or (daytime or "DAY")
return (entry.rates and (entry.rates[key] or entry.rates.DAY)) or 0
end
function Encounter.waterSlot(encounters, mapId, random)
local entry = encounters and encounters.water and encounters.water[mapId]
if not entry or not entry.slots then return nil end
local index = slotFor(Encounter.WATER_SLOT_CHANCES, roll(random, 100))
local slot = entry.slots[index]
if not slot or not slot.species then return nil end
return { species = slot.species, level = slot.level, slot = index }
end
function Encounter.waterRate(encounters, mapId)
local entry = encounters and encounters.water and encounters.water[mapId]
return (entry and entry.rate) or 0
end
-- Fishing: a rod's list is (cumulative chance, species, level) rows out of 256,
-- ending at 100%. A roll past the group's own `chance` is a bite of nothing.
function Encounter.fish(encounters, fishGroup, rod, random)
local group = encounters and encounters.fishGroups
and encounters.fishGroups[fishGroup]
if not group then return nil end
local list = group[rod or "old"]
if not list or #list == 0 then return nil end
local value = roll(random, 256)
for _, row in ipairs(list) do
if value < (row.chance or 0) then
if not row.species or row.species == "NO_ITEM" then return nil end
return { species = row.species, level = row.level }
end
end
return nil
end
-- GetFishGroupIndex (engine/events/fish.asm), the fishing half of a swarm:
-- Fish calls it before it indexes FishGroups, and it swaps FISHGROUP_QWILFISH
-- for FISHGROUP_QWILFISH_SWARM (and FISHGROUP_REMORAID for
-- FISHGROUP_REMORAID_SWARM) while wFishingSwarmFlag names that swarm. Nothing
-- else is substituted: FISHGROUP_QWILFISH_NO_SWARM is a map header value of its
-- own and never becomes a swarm group. `fishSwarm` is the FISHSWARM_* byte
-- (constants/script_constants.asm), which the port keeps in
-- save.dailyFlags.fishingSwarm and reads back through Roamers.Swarm.fishing.
Encounter.FISHSWARM_NONE = 0
Encounter.FISHSWARM_QWILFISH = 1
Encounter.FISHSWARM_REMORAID = 2
local FISH_SWARM_GROUPS = {
[Encounter.FISHSWARM_QWILFISH] = {
FISHGROUP_QWILFISH = "FISHGROUP_QWILFISH_SWARM",
},
[Encounter.FISHSWARM_REMORAID] = {
FISHGROUP_REMORAID = "FISHGROUP_REMORAID_SWARM",
},
}
-- A cache built before the extractor carried the two swarm rows has no such
-- group at all, and Fish on a missing group is a bite of nothing; falling back
-- to the map's own group keeps those rods rolling their ordinary list.
function Encounter.fishGroupFor(encounters, group, fishSwarm)
local swap = FISH_SWARM_GROUPS[fishSwarm or Encounter.FISHSWARM_NONE]
local swarmed = swap and swap[group]
if not swarmed then return group end
local groups = encounters and encounters.fishGroups
if not (groups and groups[swarmed]) then return group end
return swarmed
end
-- Which fish group a MAP belongs to lives on the map record, so a caller with
-- a map id and a rod does not have to know about groups at all.
function Encounter.fishSlot(encounters, mapId, rod, random, maps, fishSwarm)
local map = maps and maps[mapId]
local group = map and map.fishGroup
if not group then
-- Callers that already hold the map (the World does) pass it in; without
-- it, fall back to the pond, which is what an unlisted map fishes.
group = "FISHGROUP_POND"
end
group = Encounter.fishGroupFor(encounters, group, fishSwarm)
local key = rod
if rod == "OLD_ROD" then key = "old"
elseif rod == "GOOD_ROD" then key = "good"
elseif rod == "SUPER_ROD" then key = "super" end
return Encounter.fish(encounters, group, key or "old", random)
end
-- Headbutt trees: TreeMonMaps says which set a map uses and TreeMons holds
-- that set's two lists. The rows are cumulative percentages ending at -1, the
-- same shape as the fishing lists.
function Encounter.treeSet(encounters, mapId)
return encounters and encounters.trees and encounters.trees[mapId] or nil
end
-- A headbutt on the tree at (cx, cy). Whether the COMMON or the RARE list is
-- rolled comes from the tree's own coordinates on the cart -- GetTreeMons
-- hashes them so the same tree always behaves the same way -- which is what
-- keeps a player from re-rolling one tree for a rare mon.
function Encounter.treeIsRare(cx, cy)
return ((cx or 0) * 5 + (cy or 0) * 7) % 10 < 1
end
function Encounter.treeSlot(encounters, mapId, cx, cy, random)
local setName = Encounter.treeSet(encounters, mapId)
if not setName then return nil end
local set = encounters and encounters.treeSets and encounters.treeSets[setName]
if not set then return nil end
local list = Encounter.treeIsRare(cx, cy) and set.rare or set.common
if not list or #list == 0 then return nil end
local value = roll(random, 100)
local total = 0
for _, row in ipairs(list) do
total = total + (row.chance or 0)
if value < total then
if not row.species then return nil end
return { species = row.species, level = row.level }
end
end
return nil
end
return Encounter
+155
View File
@@ -0,0 +1,155 @@
-- The HP bar, exactly as the cart computes it.
--
-- Two routines, and both matter for parity because the bar is what a player
-- reads the whole battle off:
--
-- engine/pokemon/health.asm ComputeHPBarPixels
-- pixels = curHP * HP_BAR_LENGTH_PX / maxHP, floored, where
-- HP_BAR_LENGTH_PX = HP_BAR_LENGTH (6 tiles) * TILE_WIDTH (8) = 48.
-- A live mon never shows an empty bar: a result of 0 is forced to 1.
-- A fainted mon shows exactly 0.
-- When maxHP >= 256 the routine divides both the product and maxHP by 4
-- first, because hDivisor is one byte -- so a high-HP mon's bar moves in
-- coarser steps than the exact ratio would. Reproduced, not smoothed over.
--
-- home/tilemap.asm GetHPPal
-- green when pixels >= 24 (HP_BAR_LENGTH_PX * 50 / 100)
-- yellow when pixels >= 10 (HP_BAR_LENGTH_PX * 21 / 100, integer 10)
-- red otherwise
-- Note the boundaries are inclusive on the *pixel* count, not a percentage:
-- exactly half HP is green, and the yellow floor is 10/48 rather than 21%.
--
-- Colours themselves come from palettes.lua's hpBar (gfx/battle/hp_bar.pal).
local HpBar = {}
HpBar.LENGTH_TILES = 6
HpBar.TILE_WIDTH = 8
HpBar.LENGTH_PX = HpBar.LENGTH_TILES * HpBar.TILE_WIDTH -- 48
-- How tall the coloured channel is inside the bar's frame. The cart's bar
-- tiles are 8px rows with a 1px rule above and below the fill.
HpBar.CHANNEL_PX = 3
-- GetHPPal's thresholds, computed the way RGBDS does (integer division).
HpBar.GREEN_PIXELS = math.floor(HpBar.LENGTH_PX * 50 / 100) -- 24
HpBar.YELLOW_PIXELS = math.floor(HpBar.LENGTH_PX * 21 / 100) -- 10
-- Pixels of bar to fill, 0..48.
function HpBar.pixels(hp, maxHp)
hp = math.max(0, hp or 0)
maxHp = math.max(0, maxHp or 0)
if hp == 0 then return 0 end
if maxHp == 0 then return 0 end
local product = hp * HpBar.LENGTH_PX
local divisor = maxHp
if divisor >= 256 then
-- The one-byte-divisor shift, applied to both sides.
product = math.floor(product / 4)
divisor = math.floor(divisor / 4)
if divisor == 0 then divisor = 1 end
end
local pixels = math.floor(product / divisor)
if pixels == 0 then return 1 end
return math.min(HpBar.LENGTH_PX, pixels)
end
-- "green" / "yellow" / "red", keyed to match palettes.lua's hpBar table.
function HpBar.palette(pixels)
if (pixels or 0) >= HpBar.GREEN_PIXELS then return "green" end
if (pixels or 0) >= HpBar.YELLOW_PIXELS then return "yellow" end
return "red"
end
function HpBar.paletteFor(hp, maxHp)
return HpBar.palette(HpBar.pixels(hp, maxHp))
end
-- The bar's two colours out of palettes.lua: the light background the empty
-- part of the bar shows, and the fill.
function HpBar.colors(palettes, key)
local pal = palettes and palettes.hpBar and palettes.hpBar[key]
if not pal then return nil, nil end
return pal[1], pal[2]
end
-- Draw the bar at a pixel position: 6 tiles wide, black frame, white interior,
-- coloured fill from the left.
--
-- The cart builds it out of tiles -- $62 is an empty bar cell and $63..$6a are
-- the eight partial fills, so the fill really does move one pixel at a time
-- inside a fixed 48px frame, and the *unfilled* part is white, not tinted.
function HpBar.draw(palettes, hp, maxHp, px, py)
local G = love and love.graphics
if not G then return end
local pixels = HpBar.pixels(hp, maxHp)
local _, fill = HpBar.colors(palettes, HpBar.palette(pixels))
-- Frame: one pixel of black around the 48x2 channel the fill lives in.
G.setColor(0, 0, 0, 1)
G.rectangle("fill", px - 1, py - 1, HpBar.LENGTH_PX + 2, HpBar.CHANNEL_PX + 2)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", px, py, HpBar.LENGTH_PX, HpBar.CHANNEL_PX)
if pixels > 0 then
if fill then
G.setColor(fill[1] / 255, fill[2] / 255, fill[3] / 255, 1)
else
G.setColor(0.1, 0.1, 0.1, 1)
end
G.rectangle("fill", px, py, pixels, HpBar.CHANNEL_PX)
end
G.setColor(0, 0, 0, 1)
end
-- The battle HUD's bar, which is the plain bar with the cart's "HP:" prefix in
-- front of it (tiles $60/$61 in home/pokemon.asm, two tiles wide). `tx`/`ty`
-- are the tile the prefix starts at; the bar follows two tiles later, so the
-- whole assembly is 2 + 6 = 8 tiles wide.
--
-- Returns the tile column just past the bar, so a caller can put the bar's end
-- cap or the frame stub there.
function HpBar.drawWithLabel(palettes, hp, maxHp, tx, ty, font)
if font then
love.graphics.setColor(0, 0, 0, 1)
font.draw("HP:", tx * 8, ty * 8)
end
-- The bar's channel sits in the middle of the tile row, matching the tiles.
HpBar.draw(palettes, hp, maxHp, (tx + 2) * 8, ty * 8 + 2)
return tx + 2 + HpBar.LENGTH_TILES
end
-- The experience bar under the player's HUD (FillInExpBar). Same 6-tile
-- width, but it fills toward the *next* level rather than showing a ratio of a
-- maximum, and it is a flat blue with no colour states (gfx/battle/exp_bar.pal).
function HpBar.drawExp(palettes, fraction, px, py)
local G = love and love.graphics
if not G then return end
fraction = math.max(0, math.min(1, fraction or 0))
local pixels = math.floor(fraction * HpBar.LENGTH_PX)
local pal = palettes and palettes.expBar
local fill = pal and pal[2] or pal and pal[1]
G.setColor(0, 0, 0, 1)
G.rectangle("fill", px - 1, py - 1, HpBar.LENGTH_PX + 2, 3)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", px, py, HpBar.LENGTH_PX, 1)
if pixels > 0 then
if fill then
G.setColor(fill[1] / 255, fill[2] / 255, fill[3] / 255, 1)
else
G.setColor(0.3, 0.55, 0.95, 1)
end
G.rectangle("fill", px, py, pixels, 1)
end
G.setColor(0, 0, 0, 1)
end
-- How far along its current level a mon is, for the exp bar.
function HpBar.expFraction(mon, growth, levelFor)
if not (mon and growth and levelFor) then return 0 end
local level = mon.level or 1
local base = levelFor(growth, level)
local next_ = levelFor(growth, level + 1)
if next_ <= base then return 0 end
local into = (mon.experience or base) - base
return math.max(0, math.min(1, into / (next_ - base)))
end
return HpBar
+480
View File
@@ -0,0 +1,480 @@
-- 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
+223
View File
@@ -0,0 +1,223 @@
-- Prize money for beating a trainer.
--
-- Two routines, in two files, and both halves matter:
--
-- ComputeTrainerReward (engine/battle/read_trainer_party.asm) runs when the
-- party is READ, not when it is beaten: wBattleReward = the class's
-- TRNATTR_BASE_REWARD times wCurPartyLevel, and wCurPartyLevel at that
-- point is whatever the LAST row of data/trainers/parties.asm left there.
-- So Falkner pays for his level 9 Pidgeotto and not for the level 7 Pidgey
-- that came out first, whichever of them faints last.
-- WinTrainerBattle (engine/battle/core.asm) is the trainer-defeated arm, and
-- it hands out wBattleReward FOUR TIMES -- `ld c, 4`, one add per pass --
-- before doubling the figure twice for the text. That factor of four is
-- the difference between Falkner's ¥900 and a ¥225 that would look
-- plausible and be wrong.
--
-- The split is the other half of Bank of Mom: those four quarters are dealt
-- between wMoney and wMomsMoney by wMomSavingMoney, so "save some money for
-- me" is a standing 25% deduction on every trainer you beat, not a thing that
-- only happens when you walk into the house.
--
-- love-free and save-shaped: takes the Gold save table
-- (src/core/gen2/Save.lua) and writes the two accounts on it, so the battle
-- engine, the world and the tests all reach the same routine.
local Strings = require("src.core.Strings")
local Prize = {}
-- constants/misc_constants.asm. The same cap Save.MAX_MONEY carries; spelled
-- out here so this module stays usable against a bare save-shaped table.
Prize.MAX_MONEY = 999999
-- .DoubleReward saturates: `sla [hl] / rl [hl] / rl [hl] / ret nc` and then
-- $ff into all three bytes, so the shift tops out at 24 bits rather than
-- wrapping. wBattleReward is three bytes, which is where the number comes
-- from -- it is NOT the money cap.
local REWARD_CAP = 0xffffff
-- constants/ram_constants.asm, wMomSavingMoney's low bits. MOM_ACTIVE_F (bit
-- 7) is deliberately outside MOM_SAVING_MONEY_MASK: it says the bank
-- conversation has happened, not that anything is being skimmed.
--
-- "All" is bits 0 AND 1 together, not MOM_SAVING_ALL_MONEY_F -- that third
-- bit is inside the mask and is never written by anything, which is why
-- WinTrainerBattle compares against `(1 << SOME) | (1 << HALF)` rather than
-- against it.
local MOM_SAVING_MONEY_MASK = 7
local MOM_SAVING_SOME, MOM_SAVING_HALF = 1, 2
local MOM_SAVING_ALL = MOM_SAVING_SOME + MOM_SAVING_HALF
-- data/items/attributes.asm: HELD_AMULET_COIN is the only held effect that
-- reaches this file. CheckAmuletCoin (engine/battle/core.asm) latches
-- wAmuletCoin when a mon holding one is SENT OUT, and nothing clears it for
-- the rest of the battle, so the coin still pays after its holder has fainted.
Prize.AMULET_COIN = "AMULET_COIN"
-- data/text/battle.asm. Declared here and formatted at the call site so
-- Strings.source is what registers them, the same way Decorations declares
-- its own five. No line markers: every battle message in this port is one
-- flowing string that Chrome.wrap breaks to the box.
local GOT_MONEY = Strings.source("%s got %s%d for winning!")
local SENT_SOME = Strings.source("%s got %s%d for winning! Sent some to MOM!")
-- The half and all texts really are this short on the cart: they replace the
-- money line rather than following it, which is a quirk no Gold player can
-- see because BankOfMom only ever writes MOM_SAVING_SOME_MONEY_F.
local SENT_HALF = Strings.source("Sent half to MOM!")
local SENT_ALL = Strings.source("Sent all to MOM!")
-- charmap.asm: the currency glyph, the same one Chrome.money floats in front
-- of a six-digit field.
local YEN = "\xc2\xa5"
--------------------------------------------------------------------------
-- ComputeTrainerReward
--------------------------------------------------------------------------
-- hProduct is four bytes and wBattleReward takes the low two of them with a
-- zero on top, so the product is kept modulo 65536. No vanilla class can
-- reach that (255 * 100 = 25500), but a mod that raises a base reward should
-- truncate the way the cart does rather than quietly pay more.
function Prize.reward(baseMoney, level)
local base = math.floor(tonumber(baseMoney) or 0)
local lvl = math.floor(tonumber(level) or 0)
if base < 0 then base = 0 end
if lvl < 0 then lvl = 0 end
return (base * lvl) % 0x10000
end
-- The level ComputeTrainerReward would have seen: wCurPartyLevel after
-- ReadTrainerParty's loop, which is the last row it built.
function Prize.rewardLevel(party)
local last = party and party[#party]
return (last and last.level) or 0
end
local function doubleReward(value)
local doubled = (value or 0) * 2
if doubled > REWARD_CAP then return REWARD_CAP end
return doubled
end
--------------------------------------------------------------------------
-- The accounts
--------------------------------------------------------------------------
local function playerMoney(save)
local player = save and save.player
return (player and player.money) or 0
end
local function momMoney(save)
local mom = save and save.mom
return (mom and mom.savedMoney) or 0
end
-- AddBattleMoneyToAccount: a 24-bit add followed by a compare against
-- MAX_MONEY, and the overflow arm WRITES the cap rather than refusing the
-- add. The cart clamps; it does not wrap and it does not reject.
local function addToAccount(have, amount)
local total = have + amount
if total > Prize.MAX_MONEY then return Prize.MAX_MONEY end
return total
end
local function setPlayerMoney(save, value)
local player = save and save.player
if player then player.money = value end
end
local function setMomMoney(save, value)
local mom = save and save.mom
if mom then mom.savedMoney = value end
end
-- wMomSavingMoney & MOM_SAVING_MONEY_MASK. BankOfMom (engine/events/mom.asm)
-- only ever stores (1 << MOM_ACTIVE_F) or that plus (1 <<
-- MOM_SAVING_SOME_MONEY_F), so in Gold the masked byte is 0 or 1 and nothing
-- else -- which is why `savingMoney` is a boolean on this save rather than a
-- number. MOM_SAVING_HALF / _ALL are kept below anyway because the split
-- loop reads them and a Crystal-shaped save would set them.
function Prize.savingMode(save)
local mom = save and save.mom
if not (mom and mom.active and mom.savingMoney) then return 0 end
if type(mom.savingMoney) == "number" then
return mom.savingMoney % (MOM_SAVING_MONEY_MASK + 1)
end
return MOM_SAVING_SOME
end
-- `ld b, a` then the two loops: b quarters to Mom, 4 - b to the wallet. The
-- `cp (1 << SOME) | (1 << HALF) / inc a` is what turns the setting into a
-- count -- 3 means ALL, which is four quarters, not three. A masked byte of
-- 4 or more is not a value anything writes, and the cart's own text lookup
-- would run off the end of .SentToMomTexts for one, so it is read as nothing
-- rather than guessed at.
local function quartersToMom(mode)
if mode == MOM_SAVING_ALL then return 4 end
if mode == MOM_SAVING_HALF then return 2 end
if mode == MOM_SAVING_SOME then return 1 end
return 0
end
Prize.QUARTERS = 4
--------------------------------------------------------------------------
-- WinTrainerBattle
--------------------------------------------------------------------------
-- opts:
-- baseMoney the class's TRNATTR_BASE_REWARD (Trainers.lookup's baseMoney)
-- level wCurPartyLevel, i.e. Prize.rewardLevel(the trainer's party)
-- amuletCoin wAmuletCoin, latched by CheckAmuletCoin
--
-- Returns a record of what happened, which is what the caller turns into the
-- message: `total` is the figure the text prints (the quarter doubled twice),
-- `toMom` is how many of the four quarters Mom took, and `mode` is the
-- wMomSavingMoney setting the text is chosen by.
function Prize.award(save, opts)
opts = opts or {}
local quarter = Prize.reward(opts.baseMoney, opts.level)
-- `ld a, [wAmuletCoin] / and a / call nz, .DoubleReward` -- before the
-- split, so Mom's cut doubles with everything else.
if opts.amuletCoin then quarter = doubleReward(quarter) end
-- .CheckMaxedOutMomMoney: carry means wMomsMoney is BELOW the cap. With no
-- carry the whole reward goes to the wallet and the text is .KeepItAll,
-- however the savings setting is left -- Mom stops skimming once she is
-- full rather than throwing the quarter away.
local mode = 0
if momMoney(save) < Prize.MAX_MONEY then mode = Prize.savingMode(save) end
local toMom = quartersToMom(mode)
local wallet, saved = playerMoney(save), momMoney(save)
for _ = 1, toMom do saved = addToAccount(saved, quarter) end
for _ = 1, Prize.QUARTERS - toMom do wallet = addToAccount(wallet, quarter) end
setPlayerMoney(save, wallet)
setMomMoney(save, saved)
return {
quarter = quarter,
total = doubleReward(doubleReward(quarter)),
toMom = toMom,
mode = mode,
wallet = wallet,
saved = saved,
}
end
-- The line StdBattleTextbox prints, chosen by .SentToMomTexts / .KeepItAll.
function Prize.message(award, playerName)
local name = playerName or "PLAYER"
local total = (award and award.total) or 0
local mode = (award and award.mode) or 0
if mode == MOM_SAVING_ALL then return Strings(SENT_ALL) end
if mode == MOM_SAVING_HALF then return Strings(SENT_HALF) end
if mode == MOM_SAVING_SOME then
return Strings(SENT_SOME, name, YEN, total)
end
return Strings(GOT_MONEY, name, YEN, total)
end
return Prize