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.
This commit is contained in:
bryanthaboi
2026-08-23 12:10:28 -04:00
parent e88f2ef060
commit ca4d3d283c
141 changed files with 43306 additions and 583 deletions
+77 -7
View File
@@ -36,6 +36,7 @@
-- `action` names the World method that carries it out; everything else in the
-- table is that action's argument.
local GameVersion = require("src.core.GameVersion")
local Permissions = require("src.world.gen2.Permissions")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
@@ -152,14 +153,41 @@ FieldMoves.KANTO_BADGES = {
-- World:setEngineFlag / World:engineFlag route badge ids here so there is one
-- store again, the same way ENGINE_BUG_CONTEST_TIMER is routed to
-- save.bugContest rather than kept as a second copy.
FieldMoves.BADGE_FLAG = {}
for index, name in ipairs(FieldMoves.JOHTO_BADGES) do
FieldMoves.BADGE_FLAG[25 + index] = { store = "badges", name = name }
end
for index, name in ipairs(FieldMoves.KANTO_BADGES) do
FieldMoves.BADGE_FLAG[33 + index] = { store = "kantoBadges", name = name }
-- Crystal declares 162 engine flags to Gold's 93 and the badge block sits one
-- higher (constants/engine_flags.asm:39 vs pokegold's :38), so the ids come
-- from the cache's engineFlagOrder when it has one.
-- Crystal only; pokegold constants/engine_flags.asm:4-111 has no such row.
FieldMoves.FEMALE_FLAG_NAME = "ENGINE_PLAYER_IS_FEMALE"
function FieldMoves.bindEngineFlags(order)
local byName = {}
if type(order) == "table" then
-- pairs, not ipairs: a const_skip leaves a hole and ipairs would stop
-- there, silently dropping every badge past it.
for index, name in pairs(order) do
if type(index) == "number" and type(name) == "string" then
byName[name] = index - 1
end
end
end
local flags = {}
local function place(names, store, goldBase)
for index, name in ipairs(names) do
local id = byName["ENGINE_" .. name .. "BADGE"] or (goldBase + index)
flags[id] = { store = store, name = name }
end
end
place(FieldMoves.JOHTO_BADGES, "badges", 25)
place(FieldMoves.KANTO_BADGES, "kantoBadges", 33)
FieldMoves.BADGE_FLAG = flags
-- The flag IS wPlayerGender's bit 0, so World routes it to the gender byte
-- (data/events/engine_flags.asm:131, constants/engine_flags.asm:121).
FieldMoves.FEMALE_FLAG = byName[FieldMoves.FEMALE_FLAG_NAME]
return flags
end
FieldMoves.bindEngineFlags(nil)
function FieldMoves.hasBadge(save, badge)
if not badge then return true end
local owned = save and save.player and save.player.badges
@@ -357,6 +385,39 @@ FieldMoves.STATE_SPRITE = {
surf_pika = "SPRITE_SURFING_PIKACHU",
}
-- data/sprites/player_sprites.asm:8-13 KrisStateSprites, the other half of the
-- table GetPlayerSprite picks between (engine/overworld/overworld.asm:55-64).
FieldMoves.STATE_SPRITE_FEMALE = {
normal = "SPRITE_KRIS",
bike = "SPRITE_KRIS_BIKE",
surf = "SPRITE_SURF",
surf_pika = "SPRITE_SURFING_PIKACHU",
}
-- wPlayerGender's PLAYERGENDER_FEMALE_F, as the save spells it
-- (constants/ram_constants.asm:176-177).
function FieldMoves.isFemale(gender)
return gender == "female"
end
-- GetPlayerSprite's table pick and row walk
-- (engine/overworld/overworld.asm:57-64, :67-75).
function FieldMoves.stateSprite(state, gender)
local table_ = FieldMoves.isFemale(gender)
and FieldMoves.STATE_SPRITE_FEMALE or FieldMoves.STATE_SPRITE
return table_[state] or table_[FieldMoves.PLAYER_NORMAL]
end
function FieldMoves.playerSprite(gender)
return FieldMoves.stateSprite(FieldMoves.PLAYER_NORMAL, gender)
end
-- Whether the cache carries Kris at all; Gold and Silver have no
-- KrisStateSprites to extract (pokegold data/sprites/player_sprites.asm:1-6).
function FieldMoves.hasGenderChoice(sprites)
return (sprites and sprites[FieldMoves.STATE_SPRITE_FEMALE.normal]) ~= nil
end
function FieldMoves.isBiking(state)
return state == FieldMoves.PLAYER_BIKE
end
@@ -536,10 +597,14 @@ end
-- FlashFunction.CheckUseFlash: badge, then wTimeOfDayPalset == DARKNESS_PALSET
-- -- so FLASH is refused in a lit cave and on a route alike, and the refusal
-- is FieldMoveFailed's generic "Can't use that here."
--
-- ../pokecrystal/engine/events/overworld.asm:284-287 puts SpecialAerodactylChamber
-- between the two, and its carry is a second way into `.useflash`.
function FieldMoves.flashFromMenu(ctx)
local refused = badgeGate(ctx, "FLASH")
if refused then return refused end
if not ctx.dark then
local chamber = ctx.openAerodactylWall and ctx.openAerodactylWall()
if not ctx.dark and not chamber then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return { ok = true, action = "flash", text = FieldMoves.TEXT.BLINDING_FLASH }
@@ -562,6 +627,11 @@ function FieldMoves.surfFromMenu(ctx)
or FieldMoves.directionBlocked(ctx.playerColl, ctx.facing) then
return { ok = false, text = FieldMoves.TEXT.CANT_SURF }
end
-- Crystal's added `farcall CheckFacingObject`, which pokegold's :339 tags
-- BUG (../pokecrystal/engine/events/overworld.asm:364-365).
if ctx.facingObject and GameVersion.fixes().surfOntoNpc then
return { ok = false, text = FieldMoves.TEXT.CANT_SURF }
end
return {
ok = true, action = "surf",
state = FieldMoves.surfType(ctx.mon),
+225
View File
@@ -0,0 +1,225 @@
-- ../pokecrystal/engine/events/unown_walls.asm:102 DisplayUnownWords, and the
-- Unown alphabet it writes out of ../pokecrystal/constants/charmap.asm:424;
-- :1 HoOhChamber, :13 OmanyteChamber, :54 SpecialAerodactylChamber and :81
-- SpecialKabutoChamber, the four routines that open the chambers' walls.
local Assets = require("src.render.Assets")
local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local Palettes = require("src.world.gen2.Palettes")
local Sound = require("src.core.Sound")
local UnownWords = {}
UnownWords.__index = UnownWords
UnownWords.isOpaque = false
-- engine/tilesets/map_palettes.asm:40, ../pokecrystal/constants/tileset_constants.asm:55
UnownWords.BANK1 = 0x80
UnownWords.BROWN = 6
-- ../pokecrystal/engine/events/unown_walls.asm:225 .YChar, :237 .ZChar, :249 .DashChar
local FIXED = {
[0x60] = { 0x5b, 0x5c, 0x4d, 0x5d },
[0x62] = { 0x4e, 0x4f, 0x5e, 0x5f },
[0x64] = { 0x02, 0x03, 0x03, 0x02 },
}
function UnownWords.square(char)
local fixed = FIXED[char]
if fixed then return fixed[1], fixed[2], fixed[3], fixed[4] end
local base = UnownWords.BANK1 + char
return base, base + 1, base + 0x10, base + 0x11
end
-- ../pokecrystal/engine/events/unown_walls.asm:122-127
function UnownWords.origin(wall)
return (wall.x1 or 0) + 1, (wall.y1 or 0) + 2
end
-- ../pokecrystal/engine/events/unown_walls.asm:119 MenuBox, home/menu.asm:131 GetMenuBoxDims
function UnownWords.boxRect(wall)
local x1, y1 = wall.x1 or 0, wall.y1 or 0
return x1, y1, (wall.x2 or x1) - x1 + 1, (wall.y2 or y1) - y1 + 1
end
-- ../pokecrystal/engine/events/unown_walls.asm:182 _DisplayUnownWords_CopyWord
function UnownWords.layout(wall)
local tx, ty = UnownWords.origin(wall)
local out = {}
for index, char in ipairs(wall.chars or {}) do
local tl, tr, bl, br = UnownWords.square(char)
out[index] = {
tx = tx + (index - 1) * 2, ty = ty,
tl = tl, tr = tr, bl = bl, br = br,
}
end
return out
end
-- ../pokecrystal/constants/event_flags.asm:486-489, the four Crystal-only
-- EVENT_WALL_OPENED_IN_*_CHAMBER bits.
UnownWords.WALL_OPENED = {
HO_OH = 806,
KABUTO = 807,
OMANYTE = 808,
AERODACTYL = 809,
}
-- ../pokecrystal/engine/events/unown_walls.asm:60, :87 -- the
-- GetMapAttributesPointer compare the two non-special routines are gated on.
UnownWords.CHAMBER_MAPS = {
HO_OH = "RUINS_OF_ALPH_HO_OH_CHAMBER",
KABUTO = "RUINS_OF_ALPH_KABUTO_CHAMBER",
OMANYTE = "RUINS_OF_ALPH_OMANYTE_CHAMBER",
AERODACTYL = "RUINS_OF_ALPH_AERODACTYL_CHAMBER",
}
-- ../pokecrystal/engine/events/unown_walls.asm:4, :22
UnownWords.HO_OH = "HO_OH"
UnownWords.WATER_STONE = "WATER_STONE"
-- ../pokecrystal/engine/events/unown_walls.asm:16 EventFlagAction CHECK_FLAG
function UnownWords.wallOpened(events, chamber)
local flag = UnownWords.WALL_OPENED[chamber]
if not (events and flag) then return false end
return events:get(flag) and true or false
end
-- ../pokecrystal/engine/events/unown_walls.asm:8 EventFlagAction SET_FLAG
function UnownWords.openWall(events, chamber)
local flag = UnownWords.WALL_OPENED[chamber]
if not (events and flag) then return false end
if events:get(flag) then return false end
events:set(flag, true)
return true
end
-- ../pokecrystal/engine/events/unown_walls.asm:2-5: wPartySpecies[0], which
-- holds EGG rather than the hatchling's species while a slot is an egg.
function UnownWords.leadIsHoOh(party)
local lead = party and party[1]
if not lead or lead.isEgg then return false end
return lead.species == UnownWords.HO_OH
end
-- ../pokecrystal/engine/events/unown_walls.asm:28-43: wPartyCount down to 1,
-- MON_ITEM on each, so the LAST slot holding one is the one it stops at.
function UnownWords.waterStoneSlot(party)
party = party or {}
for slot = #party, 1, -1 do
local mon = party[slot]
if mon and mon.item == UnownWords.WATER_STONE then return slot end
end
return nil
end
-- ../pokecrystal/engine/events/unown_walls.asm:54, whose carry FlashFunction
-- jumps on at ../pokecrystal/engine/events/overworld.asm:285.
function UnownWords.aerodactylChamber(events, mapId)
if mapId ~= UnownWords.CHAMBER_MAPS.AERODACTYL then return false end
UnownWords.openWall(events, "AERODACTYL")
return true
end
-- ../pokecrystal/engine/events/unown_walls.asm:81, off the escape rope arm of
-- EscapeRopeOrDig (../pokecrystal/engine/events/overworld.asm:809).
function UnownWords.kabutoChamber(events, mapId)
if mapId ~= UnownWords.CHAMBER_MAPS.KABUTO then return false end
UnownWords.openWall(events, "KABUTO")
return true
end
-- ../pokecrystal/constants/script_constants.asm:319 UNOWNWORDS_*
function UnownWords.wallFor(data, scriptVar)
local walls = data and data.gen2EventTables and data.gen2EventTables.unownWalls
if not walls then return nil end
return walls[(scriptVar or 0) + 1]
end
-- opts: wall (an events.unownWalls row), world, onClose()
function UnownWords.new(game, opts)
opts = opts or {}
local self = setmetatable({}, UnownWords)
self.game = game
self.wall = opts.wall
self.world = opts.world
self.onClose = opts.onClose
self.done = false
self.squares = self.wall and UnownWords.layout(self.wall) or {}
return self
end
-- ../pokecrystal/engine/events/unown_walls.asm:155 _DisplayUnownWords_FillAttr
function UnownWords:tileset()
local world = self.world
local map = world and world.map
local def = map and map.def
local tileset = def and world.tilesets and world.tilesets[def.tileset]
if not tileset or not tileset.image then return nil end
if self.atlas == nil then
local ok, image = pcall(Assets.image, tileset.image)
self.atlas = ok and image or false
if self.atlas then self.atlas:setFilter("nearest", "nearest") end
local set = world.palettes
and Palettes.bgSet(world.palettes, def, world.daytime or "DAY")
self.colors = set and set[UnownWords.BROWN] or nil
end
if not self.atlas then return nil end
return tileset, self.atlas
end
-- ../pokecrystal/engine/events/unown_walls.asm:147 PlayClickSFX, :148 CloseWindow
function UnownWords:finish()
if self.done then return end
self.done = true
local game = self.game
if game and game.data then Sound.play(game.data, "SFX_READ_TEXT_2") end
if game and game.stack then game.stack:pop() end
if self.onClose then self.onClose() end
end
function UnownWords:update(_dt)
if self.done then return end
local input = self.game and self.game.input
if not input then return end
if input:wasPressed("a") or input:wasPressed("b") then self:finish() end
end
function UnownWords:draw()
local wall = self.wall
if not wall then return end
local bx, by, bw, bh = UnownWords.boxRect(wall)
Font.drawBox(bx, by, bw, bh)
local tileset, atlas = self:tileset()
if not tileset then return end
local perRow = tileset.tilesPerRow or 16
local aw, ah = atlas:getDimensions()
local quads = {}
local function quadFor(tile)
local q = quads[tile]
if not q then
q = love.graphics.newQuad((tile % perRow) * 8,
math.floor(tile / perRow) * 8, 8, 8, aw, ah)
quads[tile] = q
end
return q
end
local function body()
love.graphics.setColor(1, 1, 1, 1)
for _, square in ipairs(self.squares) do
local x, y = square.tx * 8, square.ty * 8
love.graphics.draw(atlas, quadFor(square.tl), x, y)
love.graphics.draw(atlas, quadFor(square.tr), x + 8, y)
love.graphics.draw(atlas, quadFor(square.bl), x, y + 8)
love.graphics.draw(atlas, quadFor(square.br), x + 8, y + 8)
end
end
if self.colors and GbcPalette.available() then
GbcPalette.with(self.colors, body)
else
body()
end
love.graphics.setColor(0, 0, 0, 1)
end
return UnownWords
+214 -25
View File
@@ -41,6 +41,7 @@ local HiddenItems = require("src.world.gen2.HiddenItems")
local Mail = require("src.core.gen2.Mail")
local Map = require("src.world.gen2.Map")
local Palettes = require("src.world.gen2.Palettes")
local UnownWords = require("src.world.gen2.UnownWords")
local Mon = require("src.battle.gen2.Mon")
local Movement = require("src.script.gen2.Movement")
local Music = require("src.core.Music")
@@ -116,6 +117,14 @@ local VAR = {
XCOORD = 0x12,
YCOORD = 0x13,
SPECIALPHONECALL = 0x14,
-- ../pokecrystal/constants/script_constants.asm:69-74, the six rows Gold's
-- table stops short of; ../pokecrystal/engine/overworld/variables.asm:62-67.
BT_WIN_STREAK = 0x15,
KURT_APRICORNS = 0x16,
CALLERID = 0x17,
BLUECARDBALANCE = 0x18,
BUENASPASSWORD = 0x19,
KENJI_BREAK = 0x1a,
}
-- constants/ram_constants.asm:293 wPlayerState. PLAYER_SKATE (2) has no row:
@@ -374,6 +383,13 @@ local function itemByIndex(items, index)
return nil
end
-- One WRAM byte, the width every VAR_* store is (`ld a, [de]` / `ld [de], a`).
local function byteOf(value)
local n = math.floor(tonumber(value) or 0)
if n < 0 then n = 0 end
return n % 256
end
-- CountSetBits over a { key = true } flag table: VAR.DEXCAUGHT, VAR.DEXSEEN
-- and VAR.BADGES are all "how many of these are set" reads off one.
local function countFlags(flags)
@@ -810,6 +826,7 @@ function World:load()
self.text = self:dataTable("gen2Text", "data/generated/text.lua") or {}
self.constants =
self:dataTable("gen2Constants", "data/generated/constants.lua") or {}
FieldMoves.bindEngineFlags(self.constants.engineFlagOrder)
-- The side tables a script command NAMES rather than carries: the phone
-- book, the in-game trades, the elevator's floor labels and the decoration
-- descriptions. A cache built before the extractor reached them has no
@@ -1256,7 +1273,7 @@ function World:load()
end,
-- ---- encounters --------------------------------------------------------
setSwarm = function(group, mapNum) self:setSwarm(group, mapNum) end,
setSwarm = function(group, mapNum, kind) self:setSwarm(group, mapNum, kind) end,
rollWild = function() return self:rollWild() end,
-- The WRAM bytes the ENGINE owns rather than the script: nil means "not
-- mine", and the VM falls back to its own sparse store.
@@ -1573,9 +1590,78 @@ function World:readVar(varId)
if varId == VAR.SPECIALPHONECALL then
return self:specialCall()
end
if varId >= VAR.BT_WIN_STREAK and varId <= VAR.KENJI_BREAK then
return self:crystalVar(varId)
end
return 0
end
-- ../pokecrystal/engine/overworld/variables.asm:62-67, the six .VarActionTable
-- rows Crystal appends past VAR_SPECIALPHONECALL.
function World:crystalVar(varId)
-- ../pokecrystal/ram/wram.asm:3286 wCurCaller, which this port parks on the
-- VM (src/script/gen2/CallAsm.lua:190).
if varId == VAR.CALLERID then
return (self.vm and self.vm.curPhoneCaller) or 0
end
local save = self.game and self.game.save
if not save then return 0 end
-- ../pokecrystal/ram/wram.asm:1703 wNrOfBeatenBattleTowerTrainers.
if varId == VAR.BT_WIN_STREAK then
return byteOf(Gen2Save.battleTowerState(save).streak)
end
-- ../pokecrystal/engine/events/kurt.asm:24,45 wKurtApricornQuantity.
if varId == VAR.KURT_APRICORNS then
return byteOf(save.kurtApricornQuantity)
end
local crystal = Gen2Save.crystalState(save)
if varId == VAR.BLUECARDBALANCE then
return byteOf(crystal.buenaPassword.balance)
end
if varId == VAR.BUENASPASSWORD then
return byteOf(crystal.buenaPassword.word)
end
-- ../pokecrystal/engine/overworld/time.asm:136 SampleKenjiBreakCountdown.
if varId == VAR.KENJI_BREAK then
return byteOf(crystal.kenjiBreak)
end
return 0
end
-- The three of them Script_writevar can reach: RETVAR_ADDR_DE rows write the
-- variable itself, RETVAR_STRBUF2 rows write the scratch buffer and are lost
-- (../pokecrystal/engine/overworld/variables.asm:21-25).
function World:setCrystalVar(varId, value)
value = byteOf(value)
if varId == VAR.CALLERID then
if self.vm then self.vm.curPhoneCaller = value end
return
end
local save = self.game and self.game.save
if not save then return end
local buena = Gen2Save.crystalState(save).buenaPassword
if varId == VAR.BLUECARDBALANCE then
buena.balance = value
elseif varId == VAR.BUENASPASSWORD then
buena.word = value
end
end
-- ../pokecrystal/engine/events/kurt.asm:19-45 SelectApricornForKurt, whose
-- byte is what `verbosegiveitemvar <BALL>, VAR_KURT_APRICORNS` hands over.
function World:setKurtApricornQuantity(count)
local save = self.game and self.game.save
if not save then return end
save.kurtApricornQuantity = byteOf(count)
end
-- ../pokecrystal/engine/overworld/time.asm:136-142, the 3..6 day roll.
function World:setKenjiBreak(days)
local save = self.game and self.game.save
if not save then return end
Gen2Save.crystalState(save).kenjiBreak = byteOf(days)
end
-- Script_checkver: 0 for Gold, 1 for Silver (constants/misc_constants.asm
-- GS_VERSION).
function World:gsVersion()
@@ -1622,6 +1708,11 @@ function World:engineFlag(flag)
local owned = player and player[badge.store]
return type(owned) == "table" and owned[badge.name] == true
end
-- Same one-store rule for ENGINE_PLAYER_IS_FEMALE, which IS wPlayerGender
-- (../pokecrystal/data/events/engine_flags.asm:131); Gold's FEMALE_FLAG is nil.
if flag == FieldMoves.FEMALE_FLAG then
return Gen2Save.isFemale(save)
end
-- Same one-store rule for the day care. data/events/engine_flags.asm:18-20
-- maps the three ids onto DAYCAREMAN_HAS_EGG_F / DAYCAREMAN_HAS_MON_F /
-- DAYCARELADY_HAS_MON_F, i.e. they ARE the bits DayCare_InitBreeding,
@@ -1661,6 +1752,13 @@ function World:setEngineFlag(flag, value)
save.player[badge.store][badge.name] = value and true or nil
return
end
-- InitGender is the only writer on the cart, so this exists only to keep a
-- stray setflag out of save.engineFlags (../pokecrystal/engine/menus/init_gender.asm:23-42).
if flag == FieldMoves.FEMALE_FLAG and save then
save.player = save.player or {}
save.player.gender = value and "female" or "male"
return
end
-- The write half of the day-care aliases. DayCareManScript_Outside's
-- `clearflag ENGINE.DAY_CARE_MAN_HAS_EGG` (maps/Route34.asm) is the ONLY cart
-- script that writes any of the three, and it is idempotent because
@@ -1697,6 +1795,9 @@ function World:writeVar(varId, value)
local state = PLAYER_STATE_BY_ID[value or 0]
if state then self:applyPlayerState(state) end
end
if varId >= VAR.BT_WIN_STREAK and varId <= VAR.KENJI_BREAK then
self:setCrystalVar(varId, value)
end
end
function World:battleType()
@@ -1854,6 +1955,16 @@ end
-- (maps/Route36.asm:58, and again at :70 on the DidntCatchSudowoodo arm) hands
-- the same slot to the Route 37 twins. So the pooled objects that read
-- through the slot have to go with it.
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1564-1575 writes
-- the sprite byte into wMapObjects, the LIVE copy, so the map def is untouched.
function World:setObjectSprite(objectId, spriteName)
local npc = self:objectEntity(objectId)
local spriteDef = spriteName and self.sprites and self.sprites[spriteName]
if not (npc and spriteDef) then return false end
if npc:setSpriteDef(spriteDef) then self:applySpritePalette(npc) end
return true
end
function World:setVariableSprite(slot, spriteIndex)
if slot == nil then return end
self.variableSprites[slot] = spriteIndex
@@ -2318,12 +2429,10 @@ end
-- the map pair and DAILYFLAGS1_SWARM are set by the one command. A port that
-- stored only the map would leave the Dunsparce call live forever, because
-- CheckSwarmFlag answers off the flag and clears the pair itself.
function World:setSwarm(group, mapNum)
function World:setSwarm(group, mapNum, kind)
local save = self.game and self.game.save
if not save then return end
save.dailyFlags = save.dailyFlags or {}
save.dailyFlags.swarm = true
save.swarmMap = self:mapIdByGroupMap(group, mapNum)
Roamers.Swarm.set(save, self:mapIdByGroupMap(group, mapNum), kind)
end
-- Script_loadwildmon's other half: roll the CURRENT map's own table the way a
@@ -3472,7 +3581,7 @@ function World:specialHooks()
takeItem = function(index, qty) return self:takeItem(index, qty) end,
engineFlag = function(flag) return self:engineFlag(flag) end,
setEngineFlag = function(flag, v) self:setEngineFlag(flag, v) end,
setSwarm = function(group, mapNum) self:setSwarm(group, mapNum) end,
setSwarm = function(group, mapNum, kind) self:setSwarm(group, mapNum, kind) end,
dayCare = function(side, onDone) self:dayCare(side, onDone) end,
givePokeMail = function(mail) return self:givePokeMail(mail) end,
checkPokeMail = function(mail, onDone) self:checkPokeMail(mail, onDone) end,
@@ -3504,6 +3613,14 @@ function World:specialHooks()
magnetTrain = function(toGoldenrod, onDone)
self:magnetTrain(toGoldenrod, onDone)
end,
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:220-223
startTowerBattle = function(trainer, onDone)
return self:startBattle({ trainer = trainer, battleTower = true }, onDone)
end,
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1552-1575
setObjectSprite = function(objectId, spriteName)
return self:setObjectSprite(objectId, spriteName)
end,
pushScreen = function(id, opts) return self:pushScreen(id, opts) end,
monName = function(index)
local id, def = speciesByIndex(
@@ -3537,9 +3654,36 @@ function World:specialHooks()
self:openScriptMenu(header, "vertical", onChoose)
end,
rareWildMon = function() return self:rareWildMon() end,
-- ../pokecrystal/engine/menus/save.asm:181 AskOverwriteSaveFile and :266
-- _SaveGameData, the two halves of Link_SaveGame (:63).
saveFileState = function() return self:saveFileState() end,
writeSave = function() return self:writeSave() end,
setKurtApricornQuantity = function(n) self:setKurtApricornQuantity(n) end,
setKenjiBreak = function(days) self:setKenjiBreak(days) end,
}
end
-- AskOverwriteSaveFile's two reads: wSaveFileExists, and
-- CompareLoadedAndSavedPlayerID (../pokecrystal/engine/menus/save.asm:224),
-- which is what picks AlreadyASaveFileText over AnotherSaveFileText.
function World:saveFileState()
local save = self.game and self.game.save
local version = save and save.version
if not Gen2Save.exists(version) then return false, false end
local stored = Gen2Save.load(version)
local mine = save and save.player and save.player.id
local theirs = stored and stored.player and stored.player.id
return true, (mine ~= nil and mine == theirs)
end
-- _SaveGameData, through the writer the SAVE menu is handed
-- (src/core/Game2.lua:435) so the save.write veto holds here too.
function World:writeSave()
local game = self.game
if not (game and game.writeSave) then return false end
return game:writeSave() ~= false
end
-- RandomUnseenWildMon's lookup half. The routine picks one of the THREE
-- RAREST grass slots on the map (`and %11 / jr z` rerolls 0, so it is slots 5,
-- 6 or 7 of the seven) and drops it if that species is also one of the FOUR
@@ -4441,6 +4585,9 @@ function World:useEscapeRope(itemId)
local items = self.game and self.game.data and self.game.data.items
local def = items and items[itemId or "ESCAPE_ROPE"]
self:takeItem(def and def.index, 1)
-- ../pokecrystal/engine/events/overworld.asm:809, between .escaperope and
-- QueueScript.
UnownWords.kabutoChamber(self.events, self.map and self.map.id)
self.queuedFieldMove = {
ok = true, action = "escaperope",
destMap = destMapId, destWarp = destWarp,
@@ -5186,6 +5333,9 @@ function World:fieldContext(mon)
facingX = fx, facingY = fy,
facingColl = map:cellCollision(fx, fy),
playerColl = map:cellCollision(p.cellX, p.cellY),
-- Crystal's SurfFunction.TrySurf is the only field move that asks
-- (../pokecrystal/engine/events/overworld.asm:364).
facingObject = self:facingObject(),
upColl = map:cellCollision(p.cellX, p.cellY - 1),
tileset = map.def and map.def.tileset,
facingBlock = blockId,
@@ -5199,6 +5349,11 @@ function World:fieldContext(mon)
-- FlashFunction tests wTimeOfDayPalset, not the map header, so a
-- PALETTE_DARK map that FLASH has already lit refuses a second FLASH.
dark = Palettes.isDarkness(map.def, self:hour(), self.flashUsed),
-- ../pokecrystal/engine/events/overworld.asm:285, called by FLASH only and
-- only after the badge gate, because it SETS the wall-opened flag.
openAerodactylWall = function()
return UnownWords.aerodactylChamber(self.events, map.id)
end,
}
end
@@ -5328,12 +5483,26 @@ function World:refreshMapImages()
return true
end
-- wPlayerGender, the byte GetPlayerSprite and AddMapObject both branch on
-- (engine/overworld/overworld.asm:61-64, engine/overworld/player_object.asm:32-39).
function World:playerGender()
local save = self.game and self.game.save
return save and save.player and save.player.gender or nil
end
-- The Chris/Kris sheet the player wears with no state on it
-- (data/sprites/player_sprites.asm:2, :9).
function World:playerSpriteName()
return FieldMoves.playerSprite(self:playerGender()) or PLAYER_SPRITE
end
-- UpdatePlayerSprite (data/sprites/player_sprites.asm ChrisStateSprites): the
-- player's sprite is a pure function of wPlayerState, which is what makes
-- getting on and off a Lapras a one-byte change rather than an animation.
function World:applyPlayerState(state)
self.playerState = state or FieldMoves.PLAYER_NORMAL
local name = FieldMoves.STATE_SPRITE[self.playerState] or PLAYER_SPRITE
local name = FieldMoves.stateSprite(self.playerState, self:playerGender())
or PLAYER_SPRITE
local def = self.sprites and self.sprites[name]
if def and self.player then
self.player:setSprite(def)
@@ -5884,6 +6053,9 @@ function World:startBattle(opts, onDone)
-- wBattleType, when the script armed one: the FORCESHINY / TRAP
-- no-escape rules live in Battle:tryRun and the force-switch handler.
battleType = opts.battleType,
-- wInBattleTowerBattle (../pokecrystal/engine/events/battle_tower/
-- battle_tower.asm:220-223), which turns DoBadgeTypeBoosts off.
battleTower = opts.battleTower,
})
self:playBattleMusic(opts)
local function pushBattle()
@@ -7397,26 +7569,43 @@ function World:interact()
return self:interactBody()
end
-- CheckFacingObject (engine/overworld/npc_movement.asm:229-248): "Double the
-- distance for counter tiles." A Pokecenter nurse and a Mart clerk stand
-- BEHIND a COLL_COUNTER tile, so the cell the player faces is the counter
-- itself and the object is one further on. Without this the press finds an
-- empty wall and nothing happens -- which is to say no nurse and no clerk in
-- the game could be talked to at all.
--
-- Only the OBJECT lookup is doubled, exactly as the cart does it: bg events
-- and the tile-collision events still read the tile actually faced.
function World:facingObjectCell()
local p = self.player
if not p then return nil end
local d = Map.DELTA[p.facing] or Map.DELTA.down
local fx, fy = p.cellX + d[1], p.cellY + d[2]
if self.map and Permissions.isCounter(self.map:cellCollision(fx, fy)) then
return p.cellX + d[1] * 2, p.cellY + d[2] * 2
end
return fx, fy
end
-- The carry CheckFacingObject answers with: IsNPCAtCoord, and then only when
-- that object's OBJECT_WALKING reads STANDING (npc_movement.asm:250-266).
function World:facingObject()
local ox, oy = self:facingObjectCell()
if not ox then return nil end
local npc = self:npcAt(ox, oy)
if npc and npc.moving then return nil end
return npc
end
function World:interactBody()
if self:busy() or not self.player or not self.vm then return false end
local p = self.player
if p.moving then return false end
local d = Map.DELTA[p.facing]
local fx, fy = p.cellX + d[1], p.cellY + d[2]
-- CheckFacingObject (engine/overworld/npc_movement.asm:229): "Double the
-- distance for counter tiles." A Pokecenter nurse and a Mart clerk stand
-- BEHIND a COLL_COUNTER tile, so the cell the player faces is the counter
-- itself and the object is one further on. Without this the press finds an
-- empty wall and nothing happens -- which is to say no nurse and no clerk in
-- the game could be talked to at all.
--
-- Only the OBJECT lookup is doubled, exactly as the cart does it: bg events
-- and the tile-collision events below still read the tile actually faced.
local ox, oy = fx, fy
if self.map and Permissions.isCounter(self.map:cellCollision(fx, fy)) then
ox, oy = p.cellX + d[1] * 2, p.cellY + d[2] * 2
end
local npc = self:npcAt(ox, oy)
local npc = self:npcAt(self:facingObjectCell())
-- TryObjectEvent writes hLastTalked for EVERY A-press dispatch; scripts
-- then use LAST_TALKED (`disappear`, `applymovementlasttalked`) without any
-- setlasttalked of their own. The port only wrote it from the explicit
@@ -8510,13 +8699,13 @@ function World:setMap(mapId, cx, cy, facing, opts)
-- GetWarpDestCoords / EnterMapConnection / EnterMapSpawnPoint write wXCoord
-- and wYCoord BEFORE HandleNewMap (data/maps/setup_scripts.asm:79-106).
local face = facing or (self.player and self.player.facing) or "down"
local chris = self.sprites and self.sprites[PLAYER_SPRITE]
local playerDef = self.sprites and self.sprites[self:playerSpriteName()]
if self.player then
self.player.cellX, self.player.cellY = cx, cy
self.player.px, self.player.py = cx * 16, cy * 16
self.player.facing = face
if chris and not self.player.sprite then
self.player:setSprite(chris)
if playerDef and not self.player.sprite then
self.player:setSprite(playerDef)
end
if not opts.seamless then
self.player.moving = false
@@ -8524,7 +8713,7 @@ function World:setMap(mapId, cx, cy, facing, opts)
self.player.targetX, self.player.targetY = nil, nil
end
else
self.player = Player.new(cx, cy, face, chris)
self.player = Player.new(cx, cy, face, playerDef)
end
-- LoadMapObjects rebuilds OBJECT_FLAGS2 from scratch, so IN_GRASS is decided
-- by the cell the player arrives on (engine/overworld/map_objects.asm:247).