Files
gen1recomp/src/core/gen2/BattleTower.lua
T
bryanthaboi ca4d3d283c Add Pokemon Crystal as a sixth supported version
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.
2026-08-23 12:10:28 -04:00

504 lines
18 KiB
Lua

-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1 and
-- ../pokecrystal/engine/events/battle_tower/rules.asm:27.
local Breeding = require("src.core.gen2.Breeding")
local HallOfFame = require("src.core.gen2.HallOfFame")
local Mon = require("src.battle.gen2.Mon")
local Save = require("src.core.gen2.Save")
local BattleTower = {}
-- ../pokecrystal/constants/battle_tower_constants.asm:1-7
BattleTower.PARTY_LENGTH = 3
BattleTower.STREAK_LENGTH = 7
BattleTower.NUM_UNIQUE_MON = 21
BattleTower.NUM_UNIQUE_TRAINERS = 70
BattleTower.TRAINERDATALENGTH = 36
-- ../pokecrystal/constants/battle_tower_constants.asm:47 GS_BALL_AVAILABLE
BattleTower.GS_BALL_AVAILABLE = 0x0b
-- ../pokecrystal/constants/battle_tower_constants.asm:55-61
BattleTower.NO_CHALLENGE = 0
BattleTower.SAVED_AND_LEFT = 1
BattleTower.CHALLENGE_IN_PROGRESS = 2
BattleTower.WON_CHALLENGE = 3
BattleTower.RECEIVED_REWARD = 4
-- ../pokecrystal/constants/battle_tower_constants.asm:63-67
BattleTower.REWARD_QUANTITY = 5
BattleTower.MIN_REWARD = "HP_UP"
BattleTower.MAX_REWARD = "CALCIUM"
BattleTower.SKIPPED_REWARD = "LUCKY_PUNCH"
BattleTower.FALLBACK_REWARD = "POTION"
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1492 bit 0,
-- :978-1008 bit 1 of sBattleTowerSaveFileFlags.
BattleTower.SAVEFILE_REGISTERED = 1
BattleTower.SAVEFILE_EXPLANATION = 2
-- ../pokecrystal/constants/battle_tower_constants.asm:11-43
BattleTower.ACTIONS = {
CHECK_EXPLANATION_READ = 0,
SET_EXPLANATION_READ = 1,
GET_CHALLENGE_STATE = 2,
SAVE_AND_QUIT = 3,
CHALLENGECANCELED = 4,
ACTION_05 = 5,
ACTION_06 = 6,
SAVELEVELGROUP = 7,
LOADLEVELGROUP = 8,
CHECKSAVEFILEISYOURS = 9,
ACTION_0A = 10,
GSBALL = 11,
ACTION_0C = 12,
ACTION_0D = 13,
EGGTICKET = 14,
ACTION_0F = 15,
ACTION_10 = 16,
ACTION_11 = 17,
ACTION_12 = 18,
ACTION_13 = 19,
ACTION_14 = 20,
ACTION_15 = 21,
ACTION_16 = 22,
ACTION_17 = 23,
LEVEL_CHECK = 24,
UBERS_CHECK = 25,
RESETDATA = 26,
GIVEREWARD = 27,
ACTION_1C = 28,
ACTION_1D = 29,
CHOOSEREWARD = 30,
SAVEOPTIONS = 31,
}
BattleTower.NUM_ACTIONS = 32
-- ../pokecrystal/ram/wram.asm:1703 wNrOfBeatenBattleTowerTrainers, which
-- ../pokecrystal/maps/BattleTowerBattleRoom.asm:38 reads back with `readmem`.
BattleTower.WRAM_NR_BEATEN = 0xcf64
-- ../pokecrystal/constants/battle_tower_constants.asm:49-53 `battletowertext`.
BattleTower.TEXT_INTRO = 1
BattleTower.TEXT_WIN = 2
BattleTower.TEXT_LOSS = 3
-- ../pokecrystal/engine/events/battle_tower/rules.asm:39-55, in the order
-- BattleTower_ExecuteJumptable prints them.
BattleTower.RULE_HEADER_TEXT = "_ExcuseMeYoureNotReadyText"
BattleTower.RULE_FAIL_TEXTS = {
"_OnlyThreeMonMayBeEnteredText",
"_TheMonMustAllBeDifferentKindsText",
"_TheMonMustNotHoldTheSameItemsText",
"_YouCantTakeAnEggText",
}
-- ../pokecrystal/engine/events/battle_tower/rules.asm:61-68
BattleTower.RULE_TAIL_TEXT = "_BattleTowerReturnWhenReadyText"
-- ../pokecrystal/engine/events/battle_tower/rules.asm:28-31 wStringBuffer2.
BattleTower.RULE_PARTY_COUNT_TEXT = "3"
-- ../pokecrystal/mobile/mobile_46.asm:3936-3958 BattleTower_UbersCheck.
BattleTower.UBERS = {
MEWTWO = true, MEW = true, LUGIA = true, HO_OH = true, CELEBI = true,
}
BattleTower.UBER_MIN_LEVEL = 70
-- ../pokecrystal/mobile/mobile_46.asm:3869-3887 Strings_L10ToL100 and
-- Strings_Ll0ToL40.
BattleTower.MAX_LEVEL_GROUP = 10
BattleTower.PRE_HOF_LEVEL_GROUPS = 4
local function counter(value)
return math.max(0, math.floor(tonumber(value) or 0))
end
-- ../pokecrystal/ram/sram.asm:147-172, on top of Save.battleTowerState.
function BattleTower.state(save)
local tower = Save.battleTowerState(save)
-- ../pokecrystal/ram/sram.asm:156 sBTChoiceOfLevelGroup
tower.levelGroup = counter(tower.levelGroup)
-- ../pokecrystal/ram/sram.asm:159 sBattleTowerSaveFileFlags
tower.saveFileFlags = counter(tower.saveFileFlags) % 256
-- ../pokecrystal/ram/sram.asm:158 sBTTrainers, $ff for an unused slot
if type(tower.trainers) ~= "table" then tower.trainers = {} end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1449-1469 s5_aa8d
if tower.reentry == nil then tower.reentry = false end
return tower
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1483 and
-- :984-989: `and 1` / `and 2`, so the answer is the MASKED byte, not a boolean.
function BattleTower.saveFileFlag(save, mask)
local tower = BattleTower.state(save)
return (math.floor(tower.saveFileFlags / mask) % 2 == 1) and mask or 0
end
function BattleTower.setSaveFileFlag(save, mask)
local tower = BattleTower.state(save)
if math.floor(tower.saveFileFlags / mask) % 2 == 0 then
tower.saveFileFlags = tower.saveFileFlags + mask
end
return tower.saveFileFlags
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:890-904
function BattleTower.resetTrainers(save)
local tower = BattleTower.state(save)
tower.trainers = {}
tower.streak = 0
return tower
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1016-1022
function BattleTower.setChallengeState(save, state)
local tower = BattleTower.state(save)
tower.challenge = counter(state)
return tower.challenge
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:206-211
function BattleTower.partyCountOk(party)
return #party == BattleTower.PARTY_LENGTH
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:218-277
-- CheckPartyValueIsUnique: eggs are skipped on both sides and a zero value
-- never collides, because `ld a, [hl] / and a / jr z, .next` drops it first.
local function valuesUnique(party, get)
local count = #party
for i = 1, count - 1 do
local mon = party[i]
if not Breeding.isEgg(mon) then
local value = get(mon)
if value ~= nil and value ~= 0 then
for j = i + 1, count do
local other = party[j]
if not Breeding.isEgg(other) and get(other) == value then
return false
end
end
end
end
end
return true
end
local function speciesOf(mon) return mon and mon.species or nil end
local function itemOf(mon) return mon and mon.item or nil end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:213-216
function BattleTower.speciesUnique(party)
return valuesUnique(party, speciesOf)
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:279-282
function BattleTower.itemsUnique(party)
return valuesUnique(party, itemOf)
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:284-299
function BattleTower.partyHasEgg(party)
for _, mon in ipairs(party) do
if Breeding.isEgg(mon) then return true end
end
return false
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:27-37 and :94-103: every
-- check runs, the first failure prints the header first, and a tail line
-- follows any failure at all. Returns the text labels in printing order.
function BattleTower.checkRules(party)
party = party or {}
local failed = {
not BattleTower.partyCountOk(party),
not BattleTower.speciesUnique(party),
not BattleTower.itemsUnique(party),
BattleTower.partyHasEgg(party),
}
local lines, any = {}, false
for index = 1, #BattleTower.RULE_FAIL_TEXTS do
if failed[index] then
if not any then
any = true
lines[#lines + 1] = BattleTower.RULE_HEADER_TEXT
end
lines[#lines + 1] = BattleTower.RULE_FAIL_TEXTS[index]
end
end
if any then lines[#lines + 1] = BattleTower.RULE_TAIL_TEXT end
return lines, any
end
-- ../pokecrystal/mobile/mobile_46.asm:1156-1166: the Hall of Fame flag is what
-- opens rooms above L40, and the last row is always CANCEL.
function BattleTower.levelGroupCount(save)
if HallOfFame.hasEntered(save) then return BattleTower.MAX_LEVEL_GROUP end
return BattleTower.PRE_HOF_LEVEL_GROUPS
end
function BattleTower.levelGroupRows(save)
local rows = {}
for group = 1, BattleTower.levelGroupCount(save) do
rows[group] = { group = group, level = group * 10 }
end
return rows
end
-- ../pokecrystal/mobile/mobile_46.asm:1264-1267 `dec a / and $fe / srl a`, the
-- BATTLE ROOM pair the hallway walks to (../pokecrystal/maps/BattleTowerHallway.asm:40-47).
function BattleTower.roomOf(group)
return math.floor((math.max(1, counter(group)) - 1) / 2)
end
-- ../pokecrystal/mobile/mobile_46.asm:3892-3934 BattleTower_LevelCheck
function BattleTower.levelCheck(party, group)
local cap = counter(group) * 10
for _, mon in ipairs(party or {}) do
if (tonumber(mon.level) or 0) > cap then return true end
end
return false
end
-- ../pokecrystal/mobile/mobile_46.asm:3936-3989 BattleTower_UbersCheck: below
-- the L70 rooms an uber under L70 is refused, and its name goes in wcd49.
function BattleTower.ubersCheck(party, group)
if counter(group) >= BattleTower.UBER_MIN_LEVEL / 10 then return nil end
for _, mon in ipairs(party or {}) do
if BattleTower.UBERS[mon.species]
and (tonumber(mon.level) or 0) < BattleTower.UBER_MIN_LEVEL then
return mon.species
end
end
return nil
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:955-976: a
-- `maskbits` roll over HP_UP..CALCIUM that folds the overshoot back to the
-- bottom of the range and rerolls LUCKY_PUNCH.
function BattleTower.rollReward(order, random)
if type(order) ~= "table" then return nil end
local low, high
for index, name in pairs(order) do
if name == BattleTower.MIN_REWARD then low = index end
if name == BattleTower.MAX_REWARD then high = index end
end
if not (low and high) then return nil end
local span = high - low + 1
local mask = 1
while mask < span do mask = mask * 2 end
for _ = 1, 64 do
local roll = random(mask) % mask
if roll >= span then roll = roll - span end
local item = order[low + roll]
if item ~= BattleTower.SKIPPED_REWARD then return item end
end
return order[low]
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:906-933: five of
-- the reward only fit when the ITEM pocket has a free slot, or already holds
-- the reward with room for five more. Otherwise the desk hands over a POTION,
-- which the script reads as "your PACK is stuffed full".
function BattleTower.rewardFits(slots, capacity, held)
if counter(slots) < counter(capacity) then return true end
if held == nil then return false end
return counter(held) < 99 - BattleTower.REWARD_QUANTITY + 1
end
-- The opponent draw, all of it ../pokecrystal/engine/events/battle_tower/
-- load_trainer.asm, over RomExtractorGen2's Crystal-only `battleTower` block.
-- data/generated/trainers.lua `battleTower`, absent on Gold and Silver.
function BattleTower.roster(data)
local trainers = data and (data.gen2Trainers or data.trainers)
local roster = trainers and trainers.battleTower
if type(roster) ~= "table" then return nil end
if type(roster.trainers) ~= "table" or type(roster.groups) ~= "table" then
return nil
end
return roster
end
-- ../pokecrystal/ram/sram.asm:162-173 sBTMonOfTrainers: the last team's three
-- species and the one before that's, which the draw refuses to repeat.
function BattleTower.prevTeams(save)
local tower = BattleTower.state(save)
local teams = tower.prevTeams
if type(teams) ~= "table" then
teams = {}
tower.prevTeams = teams
end
if type(teams.prev) ~= "table" then teams.prev = {} end
if type(teams.prevPrev) ~= "table" then teams.prevPrev = {} end
return teams
end
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:104-105 reads the
-- room back as `ld a, [wBTChoiceOfLvlGroup] / dec a`, so group 0 indexes
-- BEFORE the table; battle_tower.asm:1129-1141 is what can only save 1..10.
function BattleTower.opponentGroup(save, roster)
local groups = (roster and roster.levelGroups) or BattleTower.MAX_LEVEL_GROUP
local group = counter(BattleTower.state(save).levelGroup)
if group < 1 then return 1 end
if group > groups then return groups end
return group
end
-- load_trainer.asm:24-38 and :101-166 reroll until the roll passes; one draw
-- from the survivors is the same distribution and cannot spin. An empty
-- survivor set is where the cart's own loop would hang, so it draws unfiltered.
local function drawFiltered(count, random, accept)
local pool = {}
for index = 1, count do
if accept(index) then pool[#pool + 1] = index end
end
if #pool == 0 then return random(count) end
return pool[random(#pool)]
end
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:22-60. The roll
-- is refused while it names anybody already in sBTTrainers, and the winner is
-- written into the slot sNrOfBeatenBattleTowerTrainers points at.
function BattleTower.chooseTrainer(save, roster, random)
local tower = BattleTower.state(save)
-- :29-37, the ceiling read out of the cart: Crystal 1.0 masks with
-- BATTLETOWER_NUM_UNIQUE_MON and can only ever draw the first 21 rows.
local ceiling = counter(roster.sampleTrainers)
if ceiling < 1 or ceiling > #roster.trainers then ceiling = #roster.trainers end
local seen = {}
for slot = 1, BattleTower.STREAK_LENGTH do
local held = tonumber(tower.trainers[slot])
if held then seen[held] = true end
end
local index = drawFiltered(ceiling, random, function(row)
return not seen[row - 1]
end)
tower.trainers[math.min(counter(tower.streak), BattleTower.STREAK_LENGTH - 1)
+ 1] = index - 1
return roster.trainers[index]
end
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:94-208. Three
-- draws out of the chosen level group, each refusing a species this team
-- already holds, an ITEM this team already holds, and any species from the
-- last two teams. wBT_OTTrainer was zero-filled and its three item slots set
-- to $ff first (:7-17), which is why an unfilled slot collides with nothing.
function BattleTower.chooseTeam(save, roster, group, random)
local rows = roster.groups[group] or {}
local teams = BattleTower.prevTeams(save)
local picked, species, items = {}, {}, {}
for _ = 1, BattleTower.PARTY_LENGTH do
local index = drawFiltered(#rows, random, function(row)
local mon = rows[row]
if not mon then return false end
if species[mon.species] then return false end
if mon.item ~= nil and items[mon.item] then return false end
for _, seen in ipairs(teams.prev) do
if seen == mon.species then return false end
end
for _, seen in ipairs(teams.prevPrev) do
if seen == mon.species then return false end
end
return true
end)
local mon = rows[index]
if not mon then break end
picked[#picked + 1] = mon
species[mon.species] = true
if mon.item ~= nil then items[mon.item] = true end
end
-- :195-206, after all three: this team becomes sBTMonPrevTrainer and the
-- one it displaces becomes sBTMonPrevPrevTrainer.
local prev = {}
for slot, mon in ipairs(picked) do prev[slot] = mon.species end
teams.prevPrev = teams.prev
teams.prev = prev
return picked
end
-- The whole of `special LoadOpponentTrainerAndPokemon`, as one record. The
-- cart keeps it in wBT_OTTrainer, which is WRAM bank 3 and is NOT saved --
-- only the sBTTrainers slot and the two previous teams this walk writes are.
function BattleTower.drawOpponent(data, save, random)
local roster = BattleTower.roster(data)
if not roster then return nil end
local group = BattleTower.opponentGroup(save, roster)
local trainer = BattleTower.chooseTrainer(save, roster, random)
if not trainer then return nil end
local rows = BattleTower.chooseTeam(save, roster, group, random)
return {
index = trainer.index,
name = trainer.name,
class = trainer.class,
classId = trainer.classId,
sprite = roster.classSprites and roster.classSprites[trainer.classId],
group = group,
rows = rows,
}
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:549-570
-- CopyBTTrainer_FromBT_OT_TowBT_OTTemp, which is the first thing
-- ReadBTTrainerParty does: the challenge is now in progress and the streak
-- counter steps BEFORE the battle, which is why beating the seventh opponent
-- leaves the counter on BATTLETOWER_STREAK_LENGTH.
function BattleTower.beginBattle(save)
local tower = BattleTower.state(save)
tower.challenge = BattleTower.CHALLENGE_IN_PROGRESS
tower.streak = counter(tower.streak) + 1
return tower.streak
end
-- ReadBTTrainerParty's .otpartymon_loop (battle_tower.asm:349-376) copies the
-- whole party_struct into wOTPartyMon, so nothing here is rolled: the stored
-- stats go on top of Mon.new's, which agree with them anyway.
-- The ROM nicknames never show: load_trainer.asm:171-190 overwrites each one
-- with GetPokemonName, so the rows carry none.
function BattleTower.battleParty(data, rows)
local party = {}
for _, row in ipairs(rows or {}) do
local moves = nil
if row.moves and #row.moves > 0 then
moves = {}
for slot, id in ipairs(row.moves) do
local def = data and data.moves and data.moves[id]
local max = (def and def.pp) or 0
moves[slot] = {
id = id,
pp = (row.pp and row.pp[slot]) or max,
maxPp = max,
}
end
end
local dvs = row.dvs or {}
local statExp = row.statExp or {}
local mon = Mon.new(data, row.species, row.level, {
moves = moves,
item = row.item,
dvs = { attack = dvs.attack, defense = dvs.defense,
speed = dvs.speed, special = dvs.special },
statExp = { hp = statExp.hp, attack = statExp.attack,
defense = statExp.defense, speed = statExp.speed,
special = statExp.special },
happiness = row.happiness,
})
if mon then
if row.stats then
mon.stats = {
hp = row.stats.hp, attack = row.stats.attack,
defense = row.stats.defense, speed = row.stats.speed,
specialAttack = row.stats.specialAttack,
specialDefense = row.stats.specialDefense,
}
end
mon.maxHp = row.maxHp or mon.maxHp
mon.hp = row.hp or mon.maxHp
mon.experience = row.experience or mon.experience
party[#party + 1] = mon
end
end
return party
end
return BattleTower