mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 06:55:29 +02:00
ca4d3d283c
Crystal boots from a user-supplied ROM, imports a full cache and is playable: copyright, the Crystal intro movie, the animated title, gender select, Oak, and out into Johto. 122 of the cart's 169 script specials are implemented. Import and data - tools/make_crystal_manifest.py derives the manifest by importing make_gold_manifest as a library, with three additive keyword seams. Gold and Silver still regenerate byte-identical, which is the standing requirement for touching that generator. - crystal_symbol_deltas.py and crystal_movie_symbols.py carry the symbol delta: Crystal renames the credits mons, splits the trainer card, Pokegear and pack-pal blocks by gender, and replaces the intro and title outright. - Crystal-only manifest keys: engineFlagOrder (162 flags to Gold's 93, so the badge block sits one higher) and unownCharmap (the main charmap parser stops at the first newcharmap so the two cannot contaminate each other). Extractor - RomExtractorGen2 becomes three-edition. Crystal corrections: PAL_MAP_BANK 0x13, a flat PICS_FIX pic bank, audio bank 0x5e, the mapSongs id-100 hole, seven NPC trades, a TradeTexts stride of 8, the five Crystal tileset anim steps with per-row degrade, and the column-major trainer card portraits. - New: animated front sprites (frames, bitmasks, play and idle scripts), the Battle Tower roster, Kris assets, Mobile System GB art, and the Crystal intro and title via src/import/CrystalMovie.lua. Engine - GameVersion gains engine(id) and fixes(id). Gold and Silver keep their original bugs where the bug is not hardware dependent; Crystal gets the fixes Crystal shipped: Lucky Number boxes 10-14, surfing onto an NPC, and the Reflect and Light Screen defence overflow. - Crystal story: Suicune and Eusine, Celebi behind the GS Ball flag, the Ruins of Alph chambers, Buena, the Move Tutor, the Poke Seer, and the Battle Tower including the wInBattleTowerBattle badge-boost guard. - Kris and the gender flag, animated fronts in battle and the summary screen, and mon caught data. Verification - Every extracted asset is pixel-compared against pret's own source PNGs. - Gold caches are byte-identical before and after, file for file. - New Crystal suites plus a T2 Gen 2 tier; the full suite passes.
684 lines
28 KiB
Lua
684 lines
28 KiB
Lua
-- The two pieces of Gen 2 world state that override where a wild mon comes
|
|
-- from: the roaming legendaries, and swarms.
|
|
--
|
|
-- Both live in one module because they are the same KIND of thing -- a
|
|
-- persistent record that sits in front of a map's own encounter table -- and
|
|
-- because ChooseWildEncounter (engine/overworld/wildmons.asm) consults them in
|
|
-- one breath: _GrassWildmonLookup checks the swarm table before the Johto one,
|
|
-- and ChooseWildEncounter checks the roamers before it rolls a slot at all.
|
|
--
|
|
-- Roamers engine/overworld/wildmons.asm InitRoamMons, CheckEncounterRoamMon,
|
|
-- UpdateRoamMons, JumpRoamMons, _BackUpMapIndices
|
|
-- engine/battle/core.asm BattleEnd_HandleRoamMons
|
|
-- data/wild/roammon_maps.asm RoamMaps
|
|
-- Swarms engine/events/specials.asm StoreSwarmMapIndices, SetSwarmFlag,
|
|
-- CheckSwarmFlag, ActivateFishingSwarm
|
|
-- engine/overworld/wildmons.asm _SwarmWildmonCheck
|
|
-- data/wild/swarm_grass.asm, swarm_water.asm
|
|
--
|
|
-- Everything here is save state. A roamer that forgets where it is or how
|
|
-- hurt it is between two encounters is not a roamer, it is a random spawn.
|
|
--
|
|
-- MAP IDENTITY: the cart carries a roamer's position as a (group, number)
|
|
-- pair; the port carries map ids ("ROUTE_42"), which are the same fact with
|
|
-- the indirection removed, so every comparison below that reads as `cp d /
|
|
-- cp e` in the asm is one string compare here. GROUP_N_A / MAP_N_A -- the
|
|
-- pair InitRoamMons never writes and BattleEnd_HandleRoamMons writes when a
|
|
-- beast is caught or beaten -- is nil.
|
|
|
|
local GameVersion = require("src.core.GameVersion")
|
|
local Mon = require("src.battle.gen2.Mon")
|
|
local Runtime = require("src.mods.Runtime")
|
|
|
|
local Roamers = {}
|
|
|
|
-- roamer.moved, a Gen 2 invention: Gen 1 has no roaming legendary, so there is
|
|
-- no name to share. One event per beast that actually changed route, raised
|
|
-- from both routines that move them -- UpdateRoamMons (the one-route walk a
|
|
-- map connection or a door triggers) and JumpRoamMons (the scatter a fly or a
|
|
-- teleport triggers) -- so a tracker mod sees every hop.
|
|
--
|
|
-- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune (Gold only)
|
|
-- slot that roamer record, already carrying the new map
|
|
-- species the beast's species id
|
|
-- from the map id it left
|
|
-- to the map id it is on now
|
|
-- reason "connection" for UpdateRoamMons, "jump" for JumpRoamMons
|
|
--
|
|
-- A beast whose roll left it on the map it was already on raises nothing:
|
|
-- .Update's `jr z` re-rolls rather than standing still, so "moved" means moved.
|
|
local function emitMoved(index, slot, from, reason)
|
|
if from == slot.map then return end
|
|
if not Runtime.wants("roamer.moved") then return end
|
|
Runtime.emit("roamer.moved", {
|
|
index = index, slot = slot, species = slot.species,
|
|
from = from, to = slot.map, reason = reason,
|
|
})
|
|
end
|
|
|
|
--------------------------------------------------------------------------
|
|
-- The beasts
|
|
--------------------------------------------------------------------------
|
|
|
|
-- InitRoamMons, written out. The species order IS the slot order -- Raikou 1,
|
|
-- Entei 2, Suicune 3 -- because CheckEncounterRoamMon indexes the structs by a
|
|
-- random 0..2 and GetRoamMonHP walks them by species, so renumbering them
|
|
-- would send Suicune's damage to Raikou's byte.
|
|
Roamers.SPECIES = {
|
|
{ species = "RAIKOU", level = 40, map = "ROUTE_42" },
|
|
{ species = "ENTEI", level = 40, map = "ROUTE_37" },
|
|
{ species = "SUICUNE", level = 40, map = "ROUTE_38" },
|
|
}
|
|
Roamers.LEVEL = 40
|
|
|
|
-- pokecrystal/engine/overworld/wildmons.asm:493 seeds Raikou and Entei only,
|
|
-- so the roster is read out of the cache when the cache carries one.
|
|
local function startMapFor(species)
|
|
for _, row in ipairs(Roamers.SPECIES) do
|
|
if row.species == species then return row.map end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function Roamers.roster(encounters)
|
|
local extracted = encounters and encounters.roamMons
|
|
if type(extracted) ~= "table" or #extracted == 0 then return Roamers.SPECIES end
|
|
local rows = {}
|
|
for _, row in ipairs(extracted) do
|
|
rows[#rows + 1] = {
|
|
species = row.species,
|
|
level = row.level or Roamers.LEVEL,
|
|
map = row.map or startMapFor(row.species),
|
|
}
|
|
end
|
|
return rows
|
|
end
|
|
|
|
-- data/wild/roammon_maps.asm, entry for entry and in order. The order matters
|
|
-- twice over: `.Update` picks a connection by a two-bit index into the list, so
|
|
-- shuffling one row changes which route a beast walks to, and JumpRoamMon picks
|
|
-- an ENTRY by a four-bit index, so shuffling the rows changes where a
|
|
-- teleport drops it.
|
|
--
|
|
-- Route 40 and Route 41 are deliberately absent (they are water routes, and
|
|
-- CheckEncounterRoamMon refuses to fire while the player is surfing anyway).
|
|
Roamers.NUM_MAPS = 16
|
|
Roamers.MAPS = {
|
|
{ map = "ROUTE_29", to = { "ROUTE_30", "ROUTE_46" } },
|
|
{ map = "ROUTE_30", to = { "ROUTE_29", "ROUTE_31" } },
|
|
{ map = "ROUTE_31", to = { "ROUTE_30", "ROUTE_32", "ROUTE_36" } },
|
|
{ map = "ROUTE_32", to = { "ROUTE_36", "ROUTE_31", "ROUTE_33" } },
|
|
{ map = "ROUTE_33", to = { "ROUTE_32", "ROUTE_34" } },
|
|
{ map = "ROUTE_34", to = { "ROUTE_33", "ROUTE_35" } },
|
|
{ map = "ROUTE_35", to = { "ROUTE_34", "ROUTE_36" } },
|
|
{ map = "ROUTE_36", to = { "ROUTE_35", "ROUTE_31", "ROUTE_32", "ROUTE_37" } },
|
|
{ map = "ROUTE_37", to = { "ROUTE_36", "ROUTE_38", "ROUTE_42" } },
|
|
{ map = "ROUTE_38", to = { "ROUTE_37", "ROUTE_39", "ROUTE_42" } },
|
|
{ map = "ROUTE_39", to = { "ROUTE_38" } },
|
|
{ map = "ROUTE_42", to = { "ROUTE_43", "ROUTE_44", "ROUTE_37", "ROUTE_38" } },
|
|
{ map = "ROUTE_43", to = { "ROUTE_42", "ROUTE_44" } },
|
|
{ map = "ROUTE_44", to = { "ROUTE_42", "ROUTE_43", "ROUTE_45" } },
|
|
{ map = "ROUTE_45", to = { "ROUTE_44", "ROUTE_46" } },
|
|
{ map = "ROUTE_46", to = { "ROUTE_45", "ROUTE_29" } },
|
|
}
|
|
|
|
-- RomExtractorGen2 emits `encounters.roamMaps` in exactly the shape above, so
|
|
-- the table above is the fallback for a cache built before it did (and for a
|
|
-- caller with no cache at all, which every unit test here is).
|
|
function Roamers.mapTable(encounters)
|
|
local extracted = encounters and encounters.roamMaps
|
|
if type(extracted) == "table" and #extracted > 0 then return extracted end
|
|
return Roamers.MAPS
|
|
end
|
|
|
|
-- `.Update`'s search: walk RoamMaps for the entry whose START map is `mapId`.
|
|
-- The asm ends on `cp -1 / ret z`, which leaves b and c untouched -- a roamer
|
|
-- standing somewhere RoamMaps does not list simply does not move.
|
|
function Roamers.entryFor(mapId, encounters)
|
|
if not mapId then return nil end
|
|
for _, row in ipairs(Roamers.mapTable(encounters)) do
|
|
if row.map == mapId then return row end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- 0 .. n-1, the same convention src/battle/gen2/Encounter.lua and the battle
|
|
-- engine use for an injected random, so one seeded generator drives them all.
|
|
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
|
|
|
|
-- JumpRoamMon: a completely random RoamMaps entry, re-rolled while it lands on
|
|
-- the map the PLAYER is standing on. (The asm's `maskbits NUM_ROAMMON_MAPS /
|
|
-- cp NUM_ROAMMON_MAPS / jr nc` retry is dead code -- there are exactly 16
|
|
-- entries and the mask is four bits -- so it is not modelled.)
|
|
--
|
|
-- The retry is unbounded on the cart; `tries` caps it here so a caller that
|
|
-- hands in a degenerate random (a stub that always returns 0) cannot hang the
|
|
-- overworld. Falling out of the loop leaves the beast where it was, which is
|
|
-- the same outcome `.Update`'s not-found path has.
|
|
function Roamers.jumpOne(playerMapId, random, encounters)
|
|
local table_ = Roamers.mapTable(encounters)
|
|
local count = #table_
|
|
if count == 0 then return nil end
|
|
for _ = 1, 32 do
|
|
local row = table_[rand(random, count) + 1]
|
|
if row and row.map ~= playerMapId then return row.map end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- `.Update`: one roamer's move on a map change.
|
|
--
|
|
-- The single random byte does double duty, which is the part a paraphrase
|
|
-- always loses:
|
|
--
|
|
-- and %00011111 -> zero (1 in 32) means "jump to a completely random map"
|
|
-- and %11 -> otherwise the LOW TWO BITS of that same masked value are
|
|
-- the connection index
|
|
--
|
|
-- so the choice of connection is not independent of the choice to jump. An
|
|
-- index at or past the entry's connection count re-rolls, and so does a
|
|
-- connection that equals wRoamMons_LastMapGroup/Number -- the map the player
|
|
-- was on BEFORE the one they are on now, which is what keeps a beast from
|
|
-- following the player back and forth down one pair of routes.
|
|
function Roamers.moveOne(mapId, lastMapId, playerMapId, random, encounters)
|
|
local entry = Roamers.entryFor(mapId, encounters)
|
|
if not entry then return mapId end
|
|
local list = entry.to or {}
|
|
for _ = 1, 64 do
|
|
local value = rand(random, 256) % 32
|
|
if value == 0 then
|
|
return Roamers.jumpOne(playerMapId, random, encounters) or mapId
|
|
end
|
|
local index = value % 4
|
|
if index < #list then
|
|
local candidate = list[index + 1]
|
|
if candidate ~= lastMapId then return candidate end
|
|
end
|
|
end
|
|
return mapId
|
|
end
|
|
|
|
--------------------------------------------------------------------------
|
|
-- The save record
|
|
--------------------------------------------------------------------------
|
|
--
|
|
-- save.roamers is a slot array in the roster order above, written first by the
|
|
-- InitRoamMons special (src/script/gen2/Specials.lua) when the Burned Tower
|
|
-- basement script fires. Each slot:
|
|
--
|
|
-- species nil once the beast has been caught or beaten (GetRoamMonSpecies
|
|
-- writes 0 there, which is what stops it ever appearing again)
|
|
-- level 40, and it never changes: the roam struct has no experience
|
|
-- map the map id it occupies, nil for GROUP_N_A / MAP_N_A
|
|
-- hp its remaining HP, 0 meaning "not yet rolled". ONE byte on the
|
|
-- cart, because Raikou and Entei have under 256 HP at level 40 --
|
|
-- the port keeps the same ceiling rather than quietly widening it
|
|
-- dvs rolled at the FIRST encounter and kept, so a beast you chase all
|
|
-- game is the same individual
|
|
--
|
|
-- There is deliberately no status field: the seven-byte roam_struct
|
|
-- (macros/ram.asm) has no room for one, and LoadEnemyMon zeroes
|
|
-- wEnemyMonStatus for every wild mon, so a beast you paralyse walks it off the
|
|
-- moment it flees. Storing status would be a buff the cart does not grant.
|
|
Roamers.MAX_STORED_HP = 255
|
|
|
|
function Roamers.list(save)
|
|
return (type(save) == "table" and save.roamers) or nil
|
|
end
|
|
|
|
function Roamers.slot(save, index)
|
|
local list = Roamers.list(save)
|
|
return list and list[index] or nil
|
|
end
|
|
|
|
-- InitRoamMons. Safe to call twice: the Burned Tower script is behind a scene
|
|
-- flag, but a re-init would hand the player fresh beasts, so this only writes
|
|
-- when there is nothing there.
|
|
function Roamers.init(save, opts)
|
|
if type(save) ~= "table" then return nil end
|
|
if save.roamers and not (opts and opts.force) then return save.roamers end
|
|
local encounters = opts and (opts.encounters
|
|
or (opts.data and opts.data.gen2Encounters))
|
|
local list = {}
|
|
for _, row in ipairs(Roamers.roster(encounters)) do
|
|
list[#list + 1] = {
|
|
species = row.species,
|
|
level = row.level,
|
|
map = row.map,
|
|
-- `xor a ; generate new stats` -- the asm comments its own zero.
|
|
hp = 0,
|
|
}
|
|
end
|
|
save.roamers = list
|
|
return list
|
|
end
|
|
|
|
-- Is this beast still out there? A caught or defeated one keeps its slot but
|
|
-- has neither a species nor a map, and every path below refuses it.
|
|
function Roamers.active(slot)
|
|
return type(slot) == "table" and slot.species ~= nil and slot.map ~= nil
|
|
end
|
|
|
|
-- _BackUpMapIndices: Cur shifts into Last, then the player's current map
|
|
-- becomes Cur. It runs at the END of both UpdateRoamMons and JumpRoamMons, so
|
|
-- the "last map" a move avoids is the one the player left BEFORE the one they
|
|
-- are standing on.
|
|
function Roamers.backUpMapIndices(save, playerMapId)
|
|
if type(save) ~= "table" then return end
|
|
local marks = save.roamerMaps or {}
|
|
marks.last = marks.current
|
|
marks.current = playerMapId
|
|
save.roamerMaps = marks
|
|
end
|
|
|
|
function Roamers.lastMap(save)
|
|
return (type(save) == "table" and save.roamerMaps and save.roamerMaps.last)
|
|
or nil
|
|
end
|
|
|
|
-- UpdateRoamMons: each live beast moves along a connection, then the map
|
|
-- indices are backed up. Runs on a map CONNECTION and on a door / fall warp
|
|
-- (data/maps/setup_scripts.asm MapSetupScript_Connection and
|
|
-- MapSetupScript_Door), not on a plain warp.
|
|
function Roamers.update(save, playerMapId, random, encounters)
|
|
local list = Roamers.list(save)
|
|
if not list then return false end
|
|
local lastMapId = Roamers.lastMap(save)
|
|
for index, slot in ipairs(list) do
|
|
if Roamers.active(slot) then
|
|
local from = slot.map
|
|
slot.map = Roamers.moveOne(from, lastMapId, playerMapId, random,
|
|
encounters)
|
|
emitMoved(index, slot, from, "connection")
|
|
end
|
|
end
|
|
Roamers.backUpMapIndices(save, playerMapId)
|
|
return true
|
|
end
|
|
|
|
-- JumpRoamMons: every live beast teleports to a random roam map. This is the
|
|
-- Teleport setup script (MapSetupScript_Teleport), which is why flying or
|
|
-- teleporting across Johto scatters them instead of nudging them one route.
|
|
function Roamers.jumpAll(save, playerMapId, random, encounters)
|
|
local list = Roamers.list(save)
|
|
if not list then return false end
|
|
for index, slot in ipairs(list) do
|
|
if Roamers.active(slot) then
|
|
local from = slot.map
|
|
slot.map = Roamers.jumpOne(playerMapId, random, encounters) or slot.map
|
|
emitMoved(index, slot, from, "jump")
|
|
end
|
|
end
|
|
Roamers.backUpMapIndices(save, playerMapId)
|
|
return true
|
|
end
|
|
|
|
--------------------------------------------------------------------------
|
|
-- Meeting one
|
|
--------------------------------------------------------------------------
|
|
|
|
-- CheckEncounterRoamMon, which ChooseWildEncounter calls BEFORE it rolls a
|
|
-- slot -- so a roamer replaces the map's own encounter rather than adding to
|
|
-- it, and only on a map that has an encounter table at all.
|
|
--
|
|
-- One random byte, three gates:
|
|
-- cp 100 / jr nc -> 100 of 256 get past
|
|
-- and %11 / jr z -> three quarters of those get past
|
|
-- dec a -> 1, 2 or 3 becomes slot 0, 1 or 2
|
|
-- which is 75/256, about 29%, split evenly between the three beasts. A slot
|
|
-- whose map is not the player's map fails outright: there is NO re-roll onto
|
|
-- another beast, so two beasts sharing your route still only get one roll
|
|
-- each encounter.
|
|
--
|
|
-- Surfing refuses before anything else (`call CheckOnWater / jr z`), which is
|
|
-- what keeps Suicune out of the water on Route 42.
|
|
function Roamers.checkEncounter(save, mapId, onWater, random)
|
|
if onWater then return nil end
|
|
local list = Roamers.list(save)
|
|
if not list then return nil end
|
|
local value = rand(random, 256)
|
|
if value >= 100 then return nil end
|
|
local index = value % 4
|
|
if index == 0 then return nil end
|
|
local slot = list[index]
|
|
if not Roamers.active(slot) then return nil end
|
|
if slot.map ~= mapId then return nil end
|
|
local hit = { index = index, slot = slot, species = slot.species,
|
|
level = slot.level }
|
|
-- roamer.encountered, a Gen 2 invention, raised on the roll that REPLACES
|
|
-- the map's own encounter -- CheckEncounterRoamMon runs ahead of
|
|
-- ChooseWildEncounter, so by the time the shared encounter.species hook sees
|
|
-- anything the beast has already won the slot. This is the only notice a
|
|
-- mod gets that the wild mon about to appear is the roamer.
|
|
--
|
|
-- index / slot / species / level the same four fields the caller takes
|
|
-- mapId the map the player is standing on, which is also the beast's
|
|
--
|
|
-- Observation only: the shared encounter.species hook still runs downstream
|
|
-- and is where a mod changes what appears.
|
|
if Runtime.wants("roamer.encountered") then
|
|
Runtime.emit("roamer.encountered", {
|
|
index = index, slot = slot, species = slot.species,
|
|
level = slot.level, mapId = mapId,
|
|
})
|
|
end
|
|
return hit
|
|
end
|
|
|
|
-- Build the enemy for a roaming battle, out of the ONE party-member builder
|
|
-- (src/battle/gen2/Mon.lua) so the beast arrives with a real Gen 2 moveset.
|
|
--
|
|
-- LoadEnemyMon's two roam branches, in the order it runs them:
|
|
-- * DVs: `and a` on the stored HP decides. Zero means the struct has never
|
|
-- been used, so fresh DVs are rolled and kept; anything else reuses them.
|
|
-- * HP: zero takes .InitRoamHP, which writes the mon's FULL HP back into the
|
|
-- struct. So the first encounter both rolls and banks it.
|
|
function Roamers.beginBattle(save, index, data)
|
|
local slot = Roamers.slot(save, index)
|
|
if not Roamers.active(slot) then return nil end
|
|
local fresh = (slot.hp or 0) == 0
|
|
local mon = Mon.new(data, slot.species, slot.level or Roamers.LEVEL, {
|
|
dvs = (not fresh) and slot.dvs or nil,
|
|
})
|
|
if not mon then return nil end
|
|
slot.dvs = mon.dvs
|
|
if fresh then
|
|
-- .InitRoamHP: the struct takes the mon's max HP now, not at the end of
|
|
-- the battle.
|
|
slot.hp = math.min(Roamers.MAX_STORED_HP, mon.maxHp or 0)
|
|
else
|
|
mon.hp = math.min(slot.hp, mon.maxHp or slot.hp)
|
|
end
|
|
return mon, slot
|
|
end
|
|
|
|
-- BattleEnd_HandleRoamMons.
|
|
--
|
|
-- `outcome` is the port's battle outcome; the cart reads the low nibble of
|
|
-- wBattleResult, where WIN is 0 and both a wild flee and a player run write
|
|
-- DRAW (WildFled_EnemyFled_LinkBattleCanceled, and TryToRunAwayFromBattle's
|
|
-- .can_escape). So "caught" and "win" clear the beast for good, and
|
|
-- everything else banks its HP and moves it.
|
|
--
|
|
-- The `.not_roaming` tail is the other half of this routine and belongs to
|
|
-- ordinary wild battles: a 1-in-16 roll moves the beasts anyway, which is why
|
|
-- they drift while you grind and not only while you walk.
|
|
function Roamers.endBattle(save, index, outcome, hp, playerMapId, random,
|
|
encounters)
|
|
local slot = Roamers.slot(save, index)
|
|
if not Roamers.active(slot) then return false end
|
|
if outcome == "win" or outcome == "caught" then
|
|
slot.species = nil
|
|
slot.map = nil
|
|
slot.hp = 0
|
|
return true
|
|
end
|
|
slot.hp = math.max(0, math.min(Roamers.MAX_STORED_HP, hp or 0))
|
|
Roamers.update(save, playerMapId, random, encounters)
|
|
return true
|
|
end
|
|
|
|
-- BattleEnd_HandleRoamMons `.not_roaming`: after ANY other wild battle,
|
|
-- `call BattleRandom / and $f / ret nz` gives one chance in sixteen that the
|
|
-- beasts move.
|
|
function Roamers.afterWildBattle(save, playerMapId, random, encounters)
|
|
if not Roamers.list(save) then return false end
|
|
if rand(random, 256) % 16 ~= 0 then return false end
|
|
return Roamers.update(save, playerMapId, random, encounters)
|
|
end
|
|
|
|
-- data/wild/flee_mons.asm. TryEnemyFlee walks AlwaysFleeMons first and takes
|
|
-- the carry straight to `.Flee`, which is why a beast never gets a second turn
|
|
-- -- the roaming battle is one attack long unless it is trapped. The other
|
|
-- two lists are the same routine's 50% and 10% gates and live here so the
|
|
-- battle engine has one place to read them from.
|
|
-- pokegold/data/wild/flee_mons.asm:34 lists Suicune; pokecrystal's:34 ends the
|
|
-- list at Entei, which is what makes maps/TinTower1F.asm:119 catchable.
|
|
Roamers.ALWAYS_FLEE_BY_ENGINE = {
|
|
gs = { RAIKOU = true, ENTEI = true, SUICUNE = true },
|
|
crystal = { RAIKOU = true, ENTEI = true },
|
|
}
|
|
|
|
function Roamers.alwaysFleeMons(versionId)
|
|
return Roamers.ALWAYS_FLEE_BY_ENGINE[GameVersion.engine(versionId)]
|
|
or Roamers.ALWAYS_FLEE_BY_ENGINE.gs
|
|
end
|
|
|
|
-- src/battle/gen2/Battle.lua:4070 reads AlwaysFleeMons by species name.
|
|
Roamers.ALWAYS_FLEE = setmetatable({}, {
|
|
__index = function(_, species) return Roamers.alwaysFleeMons()[species] end,
|
|
})
|
|
|
|
Roamers.OFTEN_FLEE = {
|
|
CUBONE = true, ARTICUNO = true, ZAPDOS = true, MOLTRES = true,
|
|
QUAGSIRE = true, DELIBIRD = true, PHANPY = true, TEDDIURSA = true,
|
|
}
|
|
Roamers.SOMETIMES_FLEE = {
|
|
MAGNEMITE = true, GRIMER = true, TANGELA = true, MR__MIME = true,
|
|
EEVEE = true, PORYGON = true, DRATINI = true, DRAGONAIR = true,
|
|
TOGETIC = true, UMBREON = true, UNOWN = true, SNUBBULL = true,
|
|
HERACROSS = true,
|
|
}
|
|
|
|
--------------------------------------------------------------------------
|
|
-- Swarms
|
|
--------------------------------------------------------------------------
|
|
|
|
local Swarm = {}
|
|
Roamers.Swarm = Swarm
|
|
|
|
-- The state, on the save:
|
|
-- save.swarmMaps the stored map pairs, keyed by swarm kind
|
|
-- save.swarmMap the Gold single pair, kept as the legacy alias
|
|
-- save.dailyFlags.swarm DAILYFLAGS1_SWARM_F
|
|
-- save.dailyFlags.fishingSwarm wFishingSwarmFlag (FISHSWARM_* 0/1/2)
|
|
-- save.dailyResetDay the day wDailyResetTimer was last restarted
|
|
--
|
|
-- src/world/gen2/World.lua:setSwarm and the ActivateFishingSwarm special
|
|
-- already write the flags and the alias; this module is where they are READ
|
|
-- and where they expire.
|
|
|
|
-- constants/script_constants.asm, ActivateFishingSwarm setval arguments.
|
|
Swarm.FISH_NONE = 0
|
|
Swarm.FISH_QWILFISH = 1
|
|
Swarm.FISH_REMORAID = 2
|
|
|
|
-- pokecrystal/constants/script_constants.asm:256-257 -- the kind byte Crystal
|
|
-- puts in front of a `swarm` map, choosing between its two stored pairs.
|
|
Swarm.KIND_ORDER = { "DUNSPARCE", "YANMA" }
|
|
Swarm.KINDS = { [0] = "DUNSPARCE", [1] = "YANMA" }
|
|
Swarm.DEFAULT_KIND = "DUNSPARCE"
|
|
|
|
local KIND_NAMES = { DUNSPARCE = true, YANMA = true }
|
|
|
|
local function kindName(kind)
|
|
if type(kind) == "number" then return Swarm.KINDS[kind] or Swarm.DEFAULT_KIND end
|
|
if KIND_NAMES[kind] then return kind end
|
|
return Swarm.DEFAULT_KIND
|
|
end
|
|
|
|
-- pokegold/engine/events/specials.asm:288-293 has the one pair, so a Gold save
|
|
-- (and every save written before this record was keyed) folds onto that key.
|
|
local function goldShape(maps)
|
|
return maps.YANMA == nil
|
|
end
|
|
|
|
function Swarm.maps(save)
|
|
if type(save) ~= "table" then return nil end
|
|
local maps = save.swarmMaps
|
|
if type(maps) ~= "table" then
|
|
maps = {}
|
|
save.swarmMaps = maps
|
|
end
|
|
if save.swarmMap ~= nil and goldShape(maps) then
|
|
maps[Swarm.DEFAULT_KIND] = save.swarmMap
|
|
end
|
|
return maps
|
|
end
|
|
|
|
local function anyMap(save)
|
|
if type(save) ~= "table" then return false end
|
|
if save.swarmMap ~= nil then return true end
|
|
local maps = save.swarmMaps
|
|
if type(maps) ~= "table" then return false end
|
|
for _, name in ipairs(Swarm.KIND_ORDER) do
|
|
if maps[name] ~= nil then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- StoreSwarmMapIndices, which FALLS THROUGH into SetSwarmFlag: one command
|
|
-- writes the map pair AND the daily flag. A port that stored only the map
|
|
-- would leave the Dunsparce call live for the rest of the game, because
|
|
-- CheckSwarmFlag answers off the flag and clears the pair itself.
|
|
--
|
|
-- pokecrystal/engine/events/specials.asm:290-306 picks the pair off c instead.
|
|
function Swarm.set(save, mapId, kind)
|
|
if type(save) ~= "table" then return false end
|
|
save.dailyFlags = save.dailyFlags or {}
|
|
save.dailyFlags.swarm = true
|
|
local name = kindName(kind)
|
|
local maps = Swarm.maps(save)
|
|
maps[name] = mapId
|
|
if name == Swarm.DEFAULT_KIND then save.swarmMap = mapId end
|
|
return true
|
|
end
|
|
|
|
-- ActivateFishingSwarm: wScriptVar into wFishingSwarmFlag, then the same
|
|
-- fallthrough into SetSwarmFlag -- note it does NOT touch the map pair, so a
|
|
-- fishing swarm rides whatever map a grass swarm left behind.
|
|
function Swarm.setFishing(save, kind)
|
|
if type(save) ~= "table" then return false end
|
|
save.dailyFlags = save.dailyFlags or {}
|
|
save.dailyFlags.fishingSwarm = kind or Swarm.FISH_NONE
|
|
save.dailyFlags.swarm = true
|
|
return true
|
|
end
|
|
|
|
function Swarm.active(save)
|
|
return type(save) == "table" and save.dailyFlags ~= nil
|
|
and save.dailyFlags.swarm == true
|
|
end
|
|
|
|
function Swarm.mapId(save, kind)
|
|
if not Swarm.active(save) then return nil end
|
|
local maps = Swarm.maps(save)
|
|
return maps and maps[kindName(kind)] or nil
|
|
end
|
|
|
|
-- pokecrystal/engine/overworld/wildmons.asm:414-451 tests Dunsparce first and
|
|
-- falls through to Yanma, so a map both are on answers Dunsparce.
|
|
function Swarm.onMap(save, mapId)
|
|
if mapId == nil or not Swarm.active(save) then return nil end
|
|
local maps = Swarm.maps(save)
|
|
if not maps then return nil end
|
|
for _, name in ipairs(Swarm.KIND_ORDER) do
|
|
if maps[name] == mapId then return name end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function Swarm.fishing(save)
|
|
if not Swarm.active(save) then return Swarm.FISH_NONE end
|
|
return (save.dailyFlags and save.dailyFlags.fishingSwarm) or Swarm.FISH_NONE
|
|
end
|
|
|
|
-- CheckSwarmFlag. Returns the value it leaves in wScriptVar: 0 while the flag
|
|
-- is up, 1 once it is not -- and on that 1 it clears the fishing flag and the
|
|
-- map pair, which is the ONLY thing that ever ends a swarm. Note the polarity:
|
|
-- an `iffalse` after this special means "the swarm is still on".
|
|
function Swarm.check(save)
|
|
if type(save) ~= "table" then return 1 end
|
|
if Swarm.active(save) then return 0 end
|
|
if save.dailyFlags then save.dailyFlags.fishingSwarm = nil end
|
|
-- pokecrystal/engine/overworld/time.asm:103-112 zeroes wSwarmFlags whole,
|
|
-- which strands both of Crystal's pairs on the one daily tick.
|
|
local maps = save.swarmMaps
|
|
if type(maps) == "table" then
|
|
for _, name in ipairs(Swarm.KIND_ORDER) do maps[name] = nil end
|
|
end
|
|
save.swarmMap = nil
|
|
return 1
|
|
end
|
|
|
|
-- CheckDailyResetTimer (engine/overworld/time.asm): a one-day countdown that,
|
|
-- when it runs out, zeroes wDailyFlags1 AND wDailyFlags2 and restarts itself.
|
|
-- `day` is a day number that only has to be monotonic and comparable -- the
|
|
-- port's save carries os.date("%j") in save.rtc.day.
|
|
function Swarm.checkDailyReset(save, day)
|
|
if type(save) ~= "table" or not day then return false end
|
|
if save.dailyResetDay == nil then
|
|
save.dailyResetDay = day
|
|
return false
|
|
end
|
|
if save.dailyResetDay == day then return false end
|
|
save.dailyFlags = {}
|
|
save.dailyResetDay = day
|
|
return true
|
|
end
|
|
|
|
-- CheckTimeEvents' `.do_daily` block, in its order: the reset timer first, then
|
|
-- CheckSwarmFlag -- which is precisely why a swarm dies a day after it was set
|
|
-- rather than needing its own timer. Returns true when the swarm ended on
|
|
-- this call.
|
|
function Swarm.timeEvents(save, day)
|
|
local hadMap = anyMap(save)
|
|
local reset = Swarm.checkDailyReset(save, day)
|
|
Swarm.check(save)
|
|
return reset and hadMap and not anyMap(save)
|
|
end
|
|
|
|
-- _SwarmWildmonCheck: the swarm table is searched BEFORE the Johto/Kanto one,
|
|
-- and only when the player is standing on the swarm's own map. A swarm map
|
|
-- that is not in the swarm table falls through to the normal lookup
|
|
-- (`call LookUpWildmonsForMapDE / jr nc, .noSwarm`), which is what keeps a
|
|
-- fishing swarm from blanking the grass on Route 32.
|
|
--
|
|
-- The reader below looks for `encounters.swarmGrass` and
|
|
-- `encounters.swarmWater`, keyed by map id in exactly the shape
|
|
-- `encounters.grass` / `encounters.water` already use -- which is what
|
|
-- RomExtractorGen2 writes, since the cart's swarm tables ARE grass and water
|
|
-- records. A cache built before it did simply has no swarm rows and every
|
|
-- lookup here falls through to the map's own list.
|
|
function Swarm.entry(save, encounters, mapId, kind)
|
|
if not encounters then return nil end
|
|
if not Swarm.onMap(save, mapId) then return nil end
|
|
local table_ = (kind == "water") and encounters.swarmWater
|
|
or encounters.swarmGrass
|
|
return table_ and table_[mapId] or nil
|
|
end
|
|
|
|
-- An `encounters` view with the swarm's rows in front of the map's own, for a
|
|
-- caller that wants to keep using src/battle/gen2/Encounter.lua unchanged.
|
|
-- Returns the ORIGINAL table when no swarm applies, so the common step pays
|
|
-- nothing.
|
|
function Swarm.tables(save, encounters, mapId)
|
|
if not encounters then return encounters end
|
|
local grass = Swarm.entry(save, encounters, mapId, "grass")
|
|
local water = Swarm.entry(save, encounters, mapId, "water")
|
|
if not (grass or water) then return encounters end
|
|
local view = {}
|
|
for key, value in pairs(encounters) do view[key] = value end
|
|
if grass then
|
|
local rows = {}
|
|
for key, value in pairs(encounters.grass or {}) do rows[key] = value end
|
|
rows[mapId] = grass
|
|
view.grass = rows
|
|
end
|
|
if water then
|
|
local rows = {}
|
|
for key, value in pairs(encounters.water or {}) do rows[key] = value end
|
|
rows[mapId] = water
|
|
view.water = rows
|
|
end
|
|
return view
|
|
end
|
|
|
|
return Roamers
|