Files
gen1recomp/src/world/gen2/FieldMoves.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

881 lines
38 KiB
Lua

-- The seven HM field moves, as the love-free half of
-- engine/events/overworld.asm: "may this move be used from here", "what does
-- it do to the tile", and "which line does it print when it can't".
--
-- Every routine in that file comes in two flavours and the port needs both,
-- because they are not the same routine with a different caller:
--
-- *Function / Try*FromMenu the PACK / party-submenu path. Checks the
-- BADGE first (CheckBadge, which prints
-- "Sorry! A new BADGE is required." itself), and
-- assumes the mon is already chosen, because the
-- party list is what chose it.
-- Try*OW the A-press path out of TryTileCollisionEvent.
-- Checks the MOVE first (CheckPartyMove), then
-- the badge through CheckEngineFlag -- which is
-- CheckBadge with the text stripped off, so a
-- badgeless A press is silent, not a refusal.
--
-- The two orders are the reason a tree you cannot cut says "This tree can be
-- CUT!" and stops, while CUT off the menu with no HIVEBADGE says "Sorry! A new
-- BADGE is required." Keep them apart.
--
-- Nothing here touches love, the save writer, or the map: a routine is handed
-- a context table (World:fieldContext builds it) and hands back a result the
-- caller acts on. That is what lets tests drive all seven with a bare table.
--
-- Result shape:
-- { ok = false } the event is declined outright and
-- NOTHING is printed (TryHeadbuttOW's
-- `ret nc`, TrySurfOW's `.quit`)
-- { ok = false, text = ..., badge = } a refusal with a line, `badge` set
-- when it was CheckBadge that refused
-- { ok = true, ask = ..., ... } a yesorno first, then the action
-- { ok = true, action = "cut", ... } run it now
--
-- `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")
local FieldMoves = {}
-- ---------------------------------------------------------------- text
--
-- data/text/common_1.asm and common_2.asm, in the port's own TextBox markers:
-- \n is the text box's second line (`line`), \f is a page break (`para`), and
-- {STRBUF} is the shared wStringBuffer2 token that `text_ram wStringBuffer2`
-- expands to -- GetPartyNickname is what fills it, so it is always the
-- nickname of the mon CheckPartyMove picked.
--
-- These are transcribed rather than looked up for the same reason the headbutt
-- lines in World.lua are: engine/events/overworld.asm names each label
-- directly, the extractor only walks reachable SCRIPT pointers, and so
-- data/generated/text.lua has no key for any of them. `#` is the four-tile
-- POKé compression byte.
-- Each one is wrapped in Strings.source so the catalog generator harvests it:
-- this table is built at require time, long before Strings.load has a catalog,
-- so the lookup itself happens at the World call sites through Strings(...).
FieldMoves.TEXT = {
BADGE_REQUIRED = Strings.source("Sorry! A new BADGE\nis required."),
CANT_USE_HERE = Strings.source("Can't use that\nhere."),
USE_CUT = Strings.source("{STRBUF} used\nCUT!"),
CUT_NOTHING = Strings.source("There's nothing to\nCUT here."),
ASK_CUT = Strings.source("This tree can be\nCUT!"
.. "\fWant to use CUT?"),
CAN_CUT = Strings.source("This tree can be\nCUT!"),
BLINDING_FLASH = Strings.source("A blinding FLASH\nlights the area!"),
USED_SURF = Strings.source("{STRBUF} used\nSURF!"),
CANT_SURF = Strings.source("You can't SURF\nhere."),
ALREADY_SURFING = Strings.source("You're already\nSURFING."),
ASK_SURF = Strings.source("The water is calm.\nWant to SURF?"),
USE_WATERFALL = Strings.source("{STRBUF} used\nWATERFALL!"),
HUGE_WATERFALL = Strings.source("Wow, it's a huge\nwaterfall."),
ASK_WATERFALL = Strings.source("Do you want to use\nWATERFALL?"),
USE_STRENGTH = Strings.source("{STRBUF} used\nSTRENGTH!"),
MOVE_BOULDER = Strings.source("{STRBUF} can\nmove boulders."),
ASK_STRENGTH = Strings.source("A #MON may be\nable to move this."
.. "\fWant to use\nSTRENGTH?"),
BOULDERS_MOVE = Strings.source("Boulders may now\nbe moved!"),
BOULDERS_MAY_MOVE = Strings.source("A #MON may be\nable to move this."),
-- EscapeRopeOrDig's three lines (engine/events/overworld.asm): _UseDigText
-- and _UseEscapeRopeText open the shared warp, _CantUseDigText is DIG's own
-- refusal (the rope's .FailDig arm prints nothing and leaves the PACK to
-- its .Oak line).
USE_DIG = Strings.source("{STRBUF} used\nDIG!"),
USE_ESCAPE_ROPE = Strings.source("{PLAYER} used an\nESCAPE ROPE."),
-- TeleportFunction: _TeleportReturnText on the way out, _CantUseTeleportText
-- indoors.
TELEPORT_RETURN = Strings.source("Return to the last\n#MON CENTER."),
USE_WHIRLPOOL = Strings.source("{STRBUF} used\nWHIRLPOOL!"),
MAY_PASS_WHIRLPOOL = Strings.source("It's a vicious\nwhirlpool!"
.. "\fA #MON may be\nable to pass it."),
ASK_WHIRLPOOL = Strings.source("A whirlpool is in\nthe way."
.. "\fWant to use\nWHIRLPOOL?"),
-- Not a cart line: the stand-in destination prompt World:askFlyPoint uses
-- until the POKeGEAR's MAP card grows _FlyMap's cursor mode.
ASK_FLY_TO = Strings.source("Fly to %s?"),
}
-- ---------------------------------------------------------------- badges
--
-- The ENGINE_*BADGE each Function passes to CheckBadge. There is no pattern
-- to it -- CUT is the HIVEBADGE, SURF is the FOGBADGE, FLASH is the very first
-- badge in the game -- so it is a table, not a formula.
FieldMoves.BADGE = {
CUT = "HIVE", -- CutFunction.CheckAble
FLASH = "ZEPHYR", -- FlashFunction.CheckUseFlash
SURF = "FOG", -- SurfFunction.TrySurf / TrySurfOW
FLY = "STORM", -- FlyFunction.TryFly
STRENGTH = "PLAIN", -- StrengthFunction.TryStrength / TryStrengthOW
WHIRLPOOL = "GLACIER", -- WhirlpoolFunction.TryWhirlpool / TryWhirlpoolOW
WATERFALL = "RISING", -- WaterfallFunction.TryWaterfall / TryWaterfallOW
}
-- wJohtoBadges bit order, which is also the order src/ui/gen2/TrainerCard.lua
-- lists them in. A save may key `player.badges` by name or by that position,
-- and the trainer card already reads it both ways; this is the same read, so
-- the two screens can never disagree about who owns what.
-- NOTE the order: MINERAL is bit 4 and STORM bit 5, which is NOT the order a
-- player earns them (Chuck's STORMBADGE comes before Jasmine's MINERALBADGE).
-- constants/engine_flags.asm:38-45 is the authority and this follows it; the
-- two used to be swapped here, which silently mapped SURF's gate onto the wrong
-- bit.
FieldMoves.JOHTO_BADGES = {
"ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING",
}
FieldMoves.KANTO_BADGES = {
"BOULDER", "CASCADE", "THUNDER", "RAINBOW",
"SOUL", "MARSH", "VOLCANO", "EARTH",
}
-- ENGINE_* id -> which badge store and which name.
--
-- On the cart these are not two things: ENGINE_ZEPHYRBADGE *is* bit 0 of
-- wJohtoBadges (constants/engine_flags.asm's "; wJohtoBadges" block), so
-- `setflag ENGINE_ZEPHYRBADGE` and "the player owns the Zephyr Badge" are the
-- same write. The port had split them -- scripts wrote save.engineFlags while
-- hasBadge, VAR_BADGES, the trainer card and the save summary all read
-- save.player.badges, which nothing ever wrote. The visible effect was that no
-- field move could EVER be used: Cut, Surf, Strength and Fly all refused with
-- "Sorry! A new BADGE is required" no matter how many gyms were cleared.
-- 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.
-- 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
if type(owned) ~= "table" then return false end
if owned[badge] then return true end
for index, name in ipairs(FieldMoves.JOHTO_BADGES) do
if name == badge then return owned[index] == true end
end
return false
end
-- CheckPartyMove (engine/events/overworld.asm): the first party slot holding
-- move `moveId`. The cart leaves that slot in wCurPartyMon and every caller
-- reads it back through GetPartyNickname, so the mon comes back with its
-- index rather than a bare yes/no.
--
-- EGG slots are skipped by the cart's `.next`; the port has no egg state, so
-- there is nothing to skip yet and a mon with an `egg` field is refused here
-- for whenever there is.
--
-- fieldmove.eligibility wraps it, exactly as it wraps OverworldState:partyKnows
-- under Gen 1: the vanilla link runs first, so a mod that unlocks a field move
-- another way (an HM in the bag, a rental mon) still loses to a party that
-- really knows it. The chain is (moveId, ctx) -> mon, the Gen 1 signature; the
-- second return (the party slot) survives an empty chain but a wrapper that
-- returns one value drops it, which is why every caller here reads only `mon`.
--
-- ctx keeps Gen 1's `save` and `data` keys and adds the two this arm has that
-- Gen 1's does not: `party` (this module is love-free and takes its state as
-- arguments, so the list is not reachable from a Game) and `moveId`.
local function findMoveUser(party, moveId)
for index, mon in ipairs(party or {}) do
if not mon.egg then
for _, move in ipairs(mon.moves or {}) do
local id = type(move) == "table" and move.id or move
if id == moveId then return mon, index end
end
end
end
return nil
end
local function partyMoveUserVanilla(moveId, ctx)
return findMoveUser(ctx and ctx.party, moveId)
end
-- `fieldCtx` is World:fieldContext's table when the caller has one; it is only
-- read for the hook's ctx, so the two-argument Gen 2 callers keep working.
function FieldMoves.partyMoveUser(party, moveId, fieldCtx)
if not Runtime.wantsHook("fieldmove.eligibility") then
return findMoveUser(party, moveId)
end
return Runtime.call("fieldmove.eligibility", partyMoveUserVanilla, moveId, {
save = fieldCtx and fieldCtx.save,
data = fieldCtx and fieldCtx.data,
party = party,
moveId = moveId,
})
end
-- --------------------------------------------------------- encounter gate
--
-- CanEncounterWildMon (engine/overworld/events.asm). The branch that matters
-- is the one in the middle: a CAVE or DUNGEON map jumps STRAIGHT to the ice
-- check, skipping CheckGrassCollision entirely, which is why every walkable
-- tile of Dark Cave and Union Cave is an encounter tile and why the port --
-- which only ever asked Permissions.isGrass -- gave those maps none at all.
--
-- `noWildEncounters` is STATUSFLAGS_NO_WILD_ENCOUNTERS_F, the flag the
-- `wildoff` / `wildon` script commands drive.
function FieldMoves.canEncounterWildMon(environment, playerColl, noWild)
if noWild then return false end
if environment ~= "CAVE" and environment ~= "DUNGEON" then
if not Permissions.isEncounterCollision(playerColl) then return false end
end
-- .ice_check: shared by both arms, so an ice floor in a cave is as free of
-- encounters as an ice floor on a route.
if Permissions.isIce(playerColl) then return false end
return true
end
-- ChooseWildEncounter picks its table off CheckOnWater, i.e. the PERMISSION of
-- the tile the player stands on, not off the grass array that let the roll
-- happen. Standing in a cave rolls the grass list; surfing rolls the water
-- one, on a route and in a cave alike.
function FieldMoves.encounterTable(playerColl)
return Permissions.isWater(playerColl) and "water" or "grass"
end
-- ------------------------------------------------------------ cut blocks
--
-- data/collision/field_move_blocks.asm, verbatim. A row is
-- { facing block, replacement block, animation type }: CUT and WHIRLPOOL do
-- not edit tiles, they swap the whole 32x32 BLOCK the facing tile belongs to
-- for another block out of the same tileset, which is why one swing clears a
-- 2x2 patch of grass.
--
-- Animation type 1 is the grass swirl, 0 the falling tree
-- (OWCutAnimation reads it out of wCutWhirlpoolAnimationType).
FieldMoves.CUT_BLOCKS = {
TILESET_JOHTO = {
[0x03] = { 0x02, 1 }, -- grass
[0x5b] = { 0x3c, 0 }, -- tree
[0x5f] = { 0x3d, 0 }, -- tree
[0x63] = { 0x3f, 0 }, -- tree
[0x67] = { 0x3e, 0 }, -- tree
},
TILESET_JOHTO_MODERN = {
[0x03] = { 0x02, 1 }, -- grass
},
TILESET_KANTO = {
[0x0b] = { 0x0a, 1 }, -- grass
[0x32] = { 0x6d, 0 }, -- tree
[0x33] = { 0x6c, 0 }, -- tree
[0x34] = { 0x6f, 0 }, -- tree
[0x35] = { 0x4c, 0 }, -- tree
[0x60] = { 0x6e, 0 }, -- tree
},
TILESET_PARK = {
[0x13] = { 0x03, 1 }, -- grass
[0x03] = { 0x04, 1 }, -- grass
},
TILESET_FOREST = {
[0x0f] = { 0x17, 0 },
},
}
FieldMoves.WHIRLPOOL_BLOCKS = {
TILESET_JOHTO = {
[0x07] = { 0x36, 0 },
},
}
-- CheckOverworldTileArrays: the tileset has to be in the dictionary AND the
-- facing block has to be in that tileset's list, or the whole thing fails
-- (both `.nope` arms clear carry). Returns replacement block, animation.
function FieldMoves.blockReplacement(table_, tileset, blockId)
local rows = table_ and tileset and table_[tileset]
local row = rows and blockId and rows[blockId]
if not row then return nil end
return row[1], row[2]
end
-- CheckMapForSomethingToCut: the facing tile's collision has to be cuttable
-- AND the block it sits in has to have a replacement. Both halves are needed
-- -- a COLL_CUT_TREE in a tileset with no CutTreeBlockPointers row is the
-- cart's own "nothing to cut".
function FieldMoves.somethingToCut(ctx)
if not Permissions.isCuttable(ctx.facingColl) then return nil end
return FieldMoves.blockReplacement(
FieldMoves.CUT_BLOCKS, ctx.tileset, ctx.facingBlock)
end
-- TryWhirlpoolMenu, which is CheckMapForSomethingToCut with CheckWhirlpoolTile
-- in place of CheckCutCollision.
function FieldMoves.somethingToWhirlpool(ctx)
if not Permissions.isWhirlpool(ctx.facingColl) then return nil end
return FieldMoves.blockReplacement(
FieldMoves.WHIRLPOOL_BLOCKS, ctx.tileset, ctx.facingBlock)
end
-- CheckMapCanWaterfall: facing UP, and the tile ABOVE the player (wTileUp, not
-- the facing tile the A press found) is a waterfall. Those are the same cell
-- while the player faces up, which is exactly why the routine gets away with
-- reading wTileUp -- but the menu path has no facing tile at all, so it must
-- be wTileUp there too.
function FieldMoves.canWaterfall(ctx)
if ctx.facing ~= "up" then return false end
return Permissions.isWaterfall(ctx.upColl)
end
-- .CheckContinueWaterfall: the climb keeps applying turn_waterfall UP for as
-- long as the tile the player is STANDING on is still a waterfall tile.
function FieldMoves.waterfallContinues(playerColl)
return Permissions.isWaterfall(playerColl)
end
-- ------------------------------------------------------- player state
--
-- constants/ram_constants.asm wPlayerState. Held as strings so a save that
-- round-trips one is readable, and mapped to ChrisStateSprites
-- (data/sprites/player_sprites.asm) on the way to the renderer.
--
-- PLAYER_SKATE (2) is the one wPlayerState value with no string here: nothing
-- in Gold ever writes it, and ChrisStateSprites has no row for it either.
FieldMoves.PLAYER_NORMAL = "normal"
FieldMoves.PLAYER_BIKE = "bike"
FieldMoves.PLAYER_SURF = "surf"
FieldMoves.PLAYER_SURF_PIKA = "surf_pika"
FieldMoves.STATE_SPRITE = {
normal = "SPRITE_CHRIS",
bike = "SPRITE_CHRIS_BIKE",
surf = "SPRITE_SURF",
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
function FieldMoves.isSurfing(state)
return state == FieldMoves.PLAYER_SURF
or state == FieldMoves.PLAYER_SURF_PIKA
end
-- GetSurfType: the mon CheckPartyMove picked decides the sprite, and PIKACHU
-- is the one species that rides its own.
function FieldMoves.surfType(mon)
local species = mon and (mon.species or mon.id)
if species == "PIKACHU" then return FieldMoves.PLAYER_SURF_PIKA end
return FieldMoves.PLAYER_SURF
end
-- CheckDirection: refuse to start surfing when the tile permissions already
-- block a step in the direction the player faces. wTilePermissions is the
-- four-way "can I leave this tile" mask built by GetMovementPermissions, and
-- the port has no such mask -- but the thing it is guarding against is
-- surfing off a ledge or through a side wall, so the check is the same
-- question asked of the tile under the player.
local BLOCKED_BY = {
-- COLL_RIGHT_WALL / LEFT / UP, the HI_NYBBLE_SIDE_WALLS rows that are
-- actually used, plus the unused remainder of the block for completeness.
[0xb0] = { right = true },
[0xb1] = { left = true },
[0xb2] = { up = true },
[0xb3] = { down = true },
[0xb4] = { down = true, right = true },
[0xb5] = { down = true, left = true },
[0xb6] = { up = true, right = true },
[0xb7] = { up = true, left = true },
}
function FieldMoves.directionBlocked(playerColl, facing)
local row = playerColl and BLOCKED_BY[playerColl % 256]
return (row and row[facing]) == true
end
-- ------------------------------------------------------------------- fly
--
-- data/maps/flypoints.asm, verbatim and in order: FlyMap walks this table by
-- index, so the order is the order the picker scrolls in. Nothing in the ROM
-- points at it as script data, so it is not in landmarks.lua and is
-- transcribed here; FieldMoves.flyPoints reads landmarks.lua for the index and
-- the printed name, and simply drops any row the cache has no landmark for.
--
-- `flag` is the row's ENGINE_FLYPOINT_* id, constants/engine_flags.asm's
-- const_def count (0-based, ENGINE_RADIO_CARD is 0): the byte a town's own
-- MAPCALLBACK_NEWMAP callback sets with `setflag` the first time you walk in,
-- and what FieldMoves.hasVisitedSpawn below actually reads.
FieldMoves.FLYPOINTS = {
-- Johto
{ landmark = "LANDMARK_NEW_BARK_TOWN", spawn = "SPAWN_NEW_BARK", flag = 64 },
{ landmark = "LANDMARK_CHERRYGROVE_CITY", spawn = "SPAWN_CHERRYGROVE", flag = 65 },
{ landmark = "LANDMARK_VIOLET_CITY", spawn = "SPAWN_VIOLET", flag = 66 },
{ landmark = "LANDMARK_AZALEA_TOWN", spawn = "SPAWN_AZALEA", flag = 67 },
{ landmark = "LANDMARK_GOLDENROD_CITY", spawn = "SPAWN_GOLDENROD", flag = 69 },
{ landmark = "LANDMARK_ECRUTEAK_CITY", spawn = "SPAWN_ECRUTEAK", flag = 71 },
{ landmark = "LANDMARK_OLIVINE_CITY", spawn = "SPAWN_OLIVINE", flag = 70 },
{ landmark = "LANDMARK_CIANWOOD_CITY", spawn = "SPAWN_CIANWOOD", flag = 68 },
{ landmark = "LANDMARK_MAHOGANY_TOWN", spawn = "SPAWN_MAHOGANY", flag = 72 },
{ landmark = "LANDMARK_LAKE_OF_RAGE", spawn = "SPAWN_LAKE_OF_RAGE", flag = 73 },
{ landmark = "LANDMARK_BLACKTHORN_CITY", spawn = "SPAWN_BLACKTHORN", flag = 74 },
{ landmark = "LANDMARK_SILVER_CAVE", spawn = "SPAWN_MT_SILVER", flag = 75 },
-- Kanto
{ landmark = "LANDMARK_PALLET_TOWN", spawn = "SPAWN_PALLET", flag = 52 },
{ landmark = "LANDMARK_VIRIDIAN_CITY", spawn = "SPAWN_VIRIDIAN", flag = 53 },
{ landmark = "LANDMARK_PEWTER_CITY", spawn = "SPAWN_PEWTER", flag = 54 },
{ landmark = "LANDMARK_CERULEAN_CITY", spawn = "SPAWN_CERULEAN", flag = 55 },
{ landmark = "LANDMARK_VERMILION_CITY", spawn = "SPAWN_VERMILION", flag = 57 },
{ landmark = "LANDMARK_ROCK_TUNNEL", spawn = "SPAWN_ROCK_TUNNEL", flag = 56 },
{ landmark = "LANDMARK_LAVENDER_TOWN", spawn = "SPAWN_LAVENDER", flag = 58 },
{ landmark = "LANDMARK_CELADON_CITY", spawn = "SPAWN_CELADON", flag = 60 },
{ landmark = "LANDMARK_SAFFRON_CITY", spawn = "SPAWN_SAFFRON", flag = 59 },
{ landmark = "LANDMARK_FUCHSIA_CITY", spawn = "SPAWN_FUCHSIA", flag = 61 },
{ landmark = "LANDMARK_CINNABAR_ISLAND", spawn = "SPAWN_CINNABAR", flag = 62 },
{ landmark = "LANDMARK_INDIGO_PLATEAU", spawn = "SPAWN_INDIGO", flag = 63 },
}
-- spawn -> row, built once, so hasVisitedSpawn below does not walk the whole
-- table on every call.
local FLYPOINT_BY_SPAWN = {}
for _, row in ipairs(FieldMoves.FLYPOINTS) do
FLYPOINT_BY_SPAWN[row.spawn] = row
end
-- KANTO_FLYPOINT: the first Kanto row, 1-based here. FlyMap splits the table
-- at it and shows one region's half or the other, never both.
FieldMoves.KANTO_FLYPOINT = 13
-- HasVisitedSpawn is a bit in wVisitedSpawns, which the ENGINE_FLYPOINT_*
-- engine flags drive: a town's own MAPCALLBACK_NEWMAP callback runs `setflag
-- ENGINE_FLYPOINT_<X>` the first time the map loads, and Script_setflag
-- (Vm.lua) lands that on save.engineFlags[id] the same way ENGINE_ZEPHYRBADGE
-- and the rest of the namespace do -- see FieldMoves.FLYPOINTS' `flag`
-- column for the id.
--
-- A save from before this read the engine flags is missing that entry
-- entirely (engineFlags[id] == nil, not false), so the old bookkeeping --
-- save.visitedSpawns, a plain spawn-name set World used to write by hand --
-- is kept as the fallback for exactly that case. A save that has both
-- trusts the engine flag; a fresh save never touches visitedSpawns again.
function FieldMoves.hasVisitedSpawn(save, spawn)
if not (save and spawn) then return false end
local row = FLYPOINT_BY_SPAWN[spawn]
local engine = save.engineFlags
if row and type(engine) == "table" then
local set = engine[row.flag]
if set ~= nil then return set == true end
end
return (save.visitedSpawns or {})[spawn] == true
end
-- The rows FlyMap would actually let the cursor stop on: this region's half of
-- the table, minus every spawn CheckIfVisitedFlypoint rejects.
--
-- The Kanto half is withheld until SPAWN_INDIGO is visited (.KantoFlyMap's
-- HasVisitedSpawn gate), because with no Kanto flypoint enabled the cart's own
-- picker crashes; standing in Kanto before that shows the Johto map.
function FieldMoves.flyPoints(save, landmarks, region)
local first, last = 1, FieldMoves.KANTO_FLYPOINT - 1
if region == "kanto"
and FieldMoves.hasVisitedSpawn(save, "SPAWN_INDIGO") then
first, last = FieldMoves.KANTO_FLYPOINT, #FieldMoves.FLYPOINTS
end
local out = {}
local table_ = landmarks and landmarks.landmarks
for i = first, last do
local row = FieldMoves.FLYPOINTS[i]
if FieldMoves.hasVisitedSpawn(save, row.spawn) then
local entry = table_ and table_[row.landmark]
out[#out + 1] = {
landmark = row.landmark,
spawn = row.spawn,
index = entry and entry.index or nil,
name = entry and entry.name or row.landmark,
}
end
end
return out
end
-- ------------------------------------------------------------ menu paths
--
-- The *Function routines, each in its own jumptable order. A menu use has
-- already picked the mon, so `ctx.mon` is that mon and CheckPartyMove is not
-- run again; what these decide is the badge and the situation.
local function badgeGate(ctx, move)
local badge = FieldMoves.BADGE[move]
if FieldMoves.hasBadge(ctx.save, badge) then return nil end
-- CheckBadge queues .BadgeRequiredText itself and every caller then exits
-- the jumptable, so this is a refusal WITH a line even from the OW paths
-- that use CheckEngineFlag -- those call the flag check, not this.
return { ok = false, badge = badge, text = FieldMoves.TEXT.BADGE_REQUIRED }
end
-- CutFunction: .CheckAble (badge, then CheckMapForSomethingToCut), .DoCut,
-- .FailCut.
function FieldMoves.cutFromMenu(ctx)
local refused = badgeGate(ctx, "CUT")
if refused then return refused end
local replacement, animation = FieldMoves.somethingToCut(ctx)
if not replacement then
return { ok = false, text = FieldMoves.TEXT.CUT_NOTHING }
end
return {
ok = true, action = "cut",
replacement = replacement, animation = animation,
text = FieldMoves.TEXT.USE_CUT,
}
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
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 }
end
-- SurfFunction: .TrySurf, .DoSurf, .FailSurf, .AlreadySurfing. Note the
-- order -- already-surfing is checked BEFORE the facing tile, which is why
-- surfing up to a shore and pressing SURF says "You're already SURFING."
-- rather than "You can't SURF here."
function FieldMoves.surfFromMenu(ctx)
local refused = badgeGate(ctx, "SURF")
if refused then return refused end
if ctx.alwaysOnBike then
return { ok = false, text = FieldMoves.TEXT.CANT_SURF }
end
if FieldMoves.isSurfing(ctx.playerState) then
return { ok = false, text = FieldMoves.TEXT.ALREADY_SURFING }
end
if not Permissions.isWater(ctx.facingColl)
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),
text = FieldMoves.TEXT.USED_SURF,
}
end
-- FlyFunction.TryFly: badge, then CheckOutdoorMap -- ROUTE or TOWN and nothing
-- else, so a Pokecenter counts as indoors. The picker itself is the caller's
-- job; this only says whether it may open.
function FieldMoves.flyFromMenu(ctx)
local refused = badgeGate(ctx, "FLY")
if refused then return refused end
if ctx.environment ~= "ROUTE" and ctx.environment ~= "TOWN" then
-- .indoors falls to .FailFly, which is FieldMoveFailed.
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return { ok = true, action = "fly" }
end
-- StrengthFunction.TryStrength is the shortest of the seven: the badge, and
-- nothing else. STRENGTH from the menu always succeeds once the PLAINBADGE is
-- in, wherever the player is standing, because all it does is set
-- BIKEFLAGS_STRENGTH_ACTIVE and say so.
function FieldMoves.strengthFromMenu(ctx)
local refused = badgeGate(ctx, "STRENGTH")
if refused then return refused end
return {
ok = true, action = "strength",
text = FieldMoves.TEXT.USE_STRENGTH,
after = FieldMoves.TEXT.MOVE_BOULDER,
}
end
-- WaterfallFunction.TryWaterfall: badge, then CheckMapCanWaterfall.
function FieldMoves.waterfallFromMenu(ctx)
local refused = badgeGate(ctx, "WATERFALL")
if refused then return refused end
if not FieldMoves.canWaterfall(ctx) then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return {
ok = true, action = "waterfall", text = FieldMoves.TEXT.USE_WATERFALL,
}
end
-- WhirlpoolFunction: .TryWhirlpool (badge, TryWhirlpoolMenu), .DoWhirlpool,
-- .FailWhirlpool.
function FieldMoves.whirlpoolFromMenu(ctx)
local refused = badgeGate(ctx, "WHIRLPOOL")
if refused then return refused end
local replacement, animation = FieldMoves.somethingToWhirlpool(ctx)
if not replacement then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return {
ok = true, action = "whirlpool",
replacement = replacement, animation = animation,
text = FieldMoves.TEXT.USE_WHIRLPOOL,
}
end
-- TryHeadbuttFromMenu: no badge at all (HEADBUTT is a TM, not an HM), just the
-- facing tile.
function FieldMoves.headbuttFromMenu(ctx)
if not Permissions.isHeadbuttTree(ctx.facingColl) then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return { ok = true, action = "headbutt" }
end
-- SweetScentFromMenu (engine/events/sweet_scent.asm): QueueScript then an
-- unconditional `ld a, $1 / ld [wFieldMoveSucceeded], a` -- no badge, no
-- tile test, nothing that can refuse the press. Whether anything actually
-- turns up is answered later, by the queued script itself
-- (World:sweetScentEncounter), the same way a failed HEADBUTT still shakes
-- the tree before coming up empty.
function FieldMoves.sweetScentFromMenu(_ctx)
return { ok = true, action = "sweetscent" }
end
-- DigFunction (engine/events/overworld.asm EscapeRopeOrDig): no badge -- DIG
-- is a TM -- just .CheckCanDig's CAVE / DUNGEON environment and a live dig
-- triple, which the world hands in as ctx.canEscapeRope. .FailDig prints
-- _CantUseDigText for the move (the rope shares the check but fails silent).
function FieldMoves.digFromMenu(ctx)
local env = ctx.environment
if (env ~= "CAVE" and env ~= "DUNGEON") or not ctx.canEscapeRope then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return { ok = true, action = "dig", text = FieldMoves.TEXT.USE_DIG }
end
-- TeleportFunction .TryTeleport: CheckOutdoorMap (TOWN or ROUTE), then the
-- last spawn pair; World:warpToSpawn already resolves that pair with the
-- bedroom fallback the port boots with, so the outdoor test is the whole
-- refusal here.
function FieldMoves.teleportFromMenu(ctx)
local env = ctx.environment
if env ~= "TOWN" and env ~= "ROUTE" then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return {
ok = true, action = "teleport", text = FieldMoves.TEXT.TELEPORT_RETURN,
}
end
FieldMoves.FROM_MENU = {
CUT = FieldMoves.cutFromMenu,
FLASH = FieldMoves.flashFromMenu,
SURF = FieldMoves.surfFromMenu,
FLY = FieldMoves.flyFromMenu,
STRENGTH = FieldMoves.strengthFromMenu,
WATERFALL = FieldMoves.waterfallFromMenu,
WHIRLPOOL = FieldMoves.whirlpoolFromMenu,
HEADBUTT = FieldMoves.headbuttFromMenu,
SWEET_SCENT = FieldMoves.sweetScentFromMenu,
DIG = FieldMoves.digFromMenu,
TELEPORT = FieldMoves.teleportFromMenu,
}
-- The party submenu's field-move row. Anything the port has no routine for
-- (SOFTBOILED, ROCK_SMASH, MILK_DRINK) lands on FieldMoveFailed's line, which
-- is what the cart's own unimplemented-here branches print.
function FieldMoves.fromMenu(moveId, ctx)
local fn = FieldMoves.FROM_MENU[moveId]
if not fn then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return fn(ctx)
end
-- -------------------------------------------------------------- OW paths
--
-- Try*OW, reached from TryTileCollisionEvent. The tile has already been
-- matched by the caller (that is what picked which of these to run), so these
-- start at CheckPartyMove and the badge is CheckEngineFlag -- silent.
-- TryCutOW: no mon or no badge is NOT silent here, it is CantCutScript, so an
-- uncuttable tree still tells you it can be cut. The map check happens after
-- the YES, inside AskCutScript's `callasm .CheckMap`, which is why answering
-- YES to a tree in a tileset with no replacement block simply closes the box.
function FieldMoves.tryCutOW(ctx)
local mon = FieldMoves.partyMoveUser(ctx.party, "CUT", ctx)
if not mon or not FieldMoves.hasBadge(ctx.save, FieldMoves.BADGE.CUT) then
return { ok = false, text = FieldMoves.TEXT.CAN_CUT, took = true }
end
local replacement, animation = FieldMoves.somethingToCut(ctx)
return {
ok = true, took = true, mon = mon,
ask = FieldMoves.TEXT.ASK_CUT,
action = replacement and "cut" or nil,
replacement = replacement, animation = animation,
text = FieldMoves.TEXT.USE_CUT,
}
end
-- TryWhirlpoolOW. Unlike CUT, TryWhirlpoolMenu runs BEFORE the ask, so a
-- whirlpool in a tileset with no replacement block gets the refusal line.
function FieldMoves.tryWhirlpoolOW(ctx)
local mon = FieldMoves.partyMoveUser(ctx.party, "WHIRLPOOL", ctx)
local replacement, animation = FieldMoves.somethingToWhirlpool(ctx)
if not mon
or not FieldMoves.hasBadge(ctx.save, FieldMoves.BADGE.WHIRLPOOL)
or not replacement then
return {
ok = false, took = true, text = FieldMoves.TEXT.MAY_PASS_WHIRLPOOL,
}
end
return {
ok = true, took = true, mon = mon,
ask = FieldMoves.TEXT.ASK_WHIRLPOOL,
action = "whirlpool",
replacement = replacement, animation = animation,
text = FieldMoves.TEXT.USE_WHIRLPOOL,
}
end
-- TryWaterfallOW.
function FieldMoves.tryWaterfallOW(ctx)
local mon = FieldMoves.partyMoveUser(ctx.party, "WATERFALL", ctx)
if not mon
or not FieldMoves.hasBadge(ctx.save, FieldMoves.BADGE.WATERFALL)
or not FieldMoves.canWaterfall(ctx) then
return { ok = false, took = true, text = FieldMoves.TEXT.HUGE_WATERFALL }
end
return {
ok = true, took = true, mon = mon,
ask = FieldMoves.TEXT.ASK_WATERFALL,
action = "waterfall", text = FieldMoves.TEXT.USE_WATERFALL,
}
end
-- TrySurfOW. Every failure arm is `.quit` -- `xor a`, no script, no text --
-- so a shore with no SURF mon is a dead A press, not a refusal. It is also
-- the LAST thing TryTileCollisionEvent tries, so it can afford to be silent.
function FieldMoves.trySurfOW(ctx)
if FieldMoves.isSurfing(ctx.playerState) then return { ok = false } end
if not Permissions.isWater(ctx.facingColl) then return { ok = false } end
if FieldMoves.directionBlocked(ctx.playerColl, ctx.facing) then
return { ok = false }
end
if not FieldMoves.hasBadge(ctx.save, FieldMoves.BADGE.SURF) then
return { ok = false }
end
local mon = FieldMoves.partyMoveUser(ctx.party, "SURF", ctx)
if not mon then return { ok = false } end
if ctx.alwaysOnBike then return { ok = false } end
return {
ok = true, took = true, mon = mon,
ask = FieldMoves.TEXT.ASK_SURF,
action = "surf", state = FieldMoves.surfType(mon),
text = FieldMoves.TEXT.USED_SURF,
}
end
-- TryStrengthOW, which is a callasm inside AskStrengthScript rather than a
-- tile event: walking into a boulder runs the boulder's own script, and that
-- script asks this which of its three lines to print. The three wScriptVar
-- values are transcribed as strings:
--
-- 0 "already" STRENGTH is already active -> BouldersMoveText
-- 1 "nope" no mon / no PLAINBADGE -> BouldersMayMoveText
-- 2 "ask" may be turned on right now -> AskStrengthScript
--
-- Note the inversion in the cart: `bit BIKEFLAGS_STRENGTH_ACTIVE_F` jumps to
-- .already_using when the bit is CLEAR, so 2 is the not-yet case and 0 the
-- already-on one. Reading that backwards swaps the two lines.
function FieldMoves.tryStrengthOW(ctx)
local mon = FieldMoves.partyMoveUser(ctx.party, "STRENGTH", ctx)
if not mon or not FieldMoves.hasBadge(ctx.save, FieldMoves.BADGE.STRENGTH) then
return { ok = false, took = true, text = FieldMoves.TEXT.BOULDERS_MAY_MOVE }
end
if ctx.strengthActive then
return { ok = false, took = true, text = FieldMoves.TEXT.BOULDERS_MOVE }
end
return {
ok = true, took = true, mon = mon,
ask = FieldMoves.TEXT.ASK_STRENGTH,
action = "strength",
text = FieldMoves.TEXT.USE_STRENGTH,
after = FieldMoves.TEXT.MOVE_BOULDER,
}
end
return FieldMoves