-- Gen 2 overworld vertical slice: COLL_* collision, warps, connected -- neighbor strips (RBY-style), seamless edge crossings, survey zoom, -- OW sprites + SPRITEMOVEDATA walk/spin paths on current map and neighbor -- strips. Mounted from Game2; leaves Gen 1 Map.lua alone. local Apricorns = require("src.core.gen2.Apricorns") -- The mod-override choke point: every generated-cache path a mod can shadow -- with overrides/ or an AssetTransform output has to be resolved through here -- (src/render/Assets.lua:33), which a raw love.graphics.newImage bypasses. local Assets = require("src.render.Assets") local Bag = require("src.inventory.Bag") local Battle = require("src.battle.gen2.Battle") local BattleMusic = require("src.battle.gen2.BattleMusic") local Bike = require("src.world.gen2.Bike") local BorderFill = require("src.world.gen2.BorderFill") local Boxes = require("src.core.gen2.Boxes") local Breeding = require("src.core.gen2.Breeding") local BugContest = require("src.core.gen2.BugContest") local CallAsm = require("src.script.gen2.CallAsm") local Camera = require("src.render.Camera") local Clock = require("src.core.gen2.Clock") local CatchTutorial = require("src.core.gen2.CatchTutorial") local Decorations = require("src.core.gen2.Decorations") local MomShopping = require("src.core.gen2.MomShopping") local CmdQueue = require("src.world.gen2.CmdQueue") local Encounter = require("src.battle.gen2.Encounter") local ChoiceBox = require("src.ui.ChoiceBox") local Events = require("src.world.gen2.Events") local FieldMoves = require("src.world.gen2.FieldMoves") local FixedStep = require("src.core.FixedStep") local Follower = require("src.world.gen2.Follower") local Font = require("src.render.Font") -- Two call sites only (World:step's tail, World:interact), both no-ops until -- a mod has taken a facade (src/mods/Gen2Compat.lua). local Gen1Facade = require("src.mods.Gen2Compat") local GbcPalette = require("src.render.GbcPalette") local Gen2Save = require("src.core.gen2.Save") local HallOfFame = require("src.core.gen2.HallOfFame") 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 Mon = require("src.battle.gen2.Mon") local Movement = require("src.script.gen2.Movement") local Music = require("src.core.Music") local NPC = require("src.world.gen2.Npc") local Party = require("src.pokemon.Party") local Permissions = require("src.world.gen2.Permissions") local Pipelines = require("src.render.Pipelines") local Player = require("src.world.gen2.Player") local Pokerus = require("src.core.gen2.Pokerus") local Roamers = require("src.core.gen2.Roamers") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local Sound = require("src.core.Sound") local StepEvents = require("src.world.gen2.StepEvents") local Tilt = require("src.render.Tilt") local Strings = require("src.core.Strings") local TextBox = require("src.render.TextBox") local TrainerHouse = require("src.world.gen2.TrainerHouse") local Trainers = require("src.world.gen2.Trainers") local Unown = require("src.core.gen2.Unown") local Vm = require("src.script.gen2.Vm") local Zoom = require("src.render.Zoom") -- SFX_* indices from audio/sfx_pointers.asm (constants.sfxOrder). local SFX_ITEM = 1 -- Script_specialsound (engine/overworld/scripting.asm:476) is not a fixed cue: -- it farcalls CheckItemPocket over wCurItem and rings SFX_GET_TM for the TM/HM -- pocket, SFX_ITEM for every other one. That is the sound inside GiveItemScript, -- so it is what every `verbosegiveitem` plays. local SFX_GET_TM = 0x9b -- SFX_READ_TEXT_2, the blip PlayTalkObject opens every bg event read on -- (engine/overworld/events.asm). local SFX_READ_TEXT_2 = 8 -- SFX_SECOND_PART_OF_ITEMFINDER, the ding the heal machine rings as each -- ball lands on it (engine/events/heal_machine_anim.asm .party_loop). Gen 2 -- has no SFX_HEAL_MACHINE: the rising chime over the flashing is MUSIC_HEAL, -- a song, not an sfx. local SFX_SECOND_PART_OF_ITEMFINDER = 0x12 -- .HOF_PlaySFX's pair: the Game Freak chime over the Hall of Fame machine's -- flashing, then SFX_BOOT_PC as it settles. local SFX_GAME_FREAK_LOGO_GS = 0xaa local SFX_BOOT_PC = 0x0d -- SFX_SANDSTORM, the rattle ShakeHeadbuttTree plays over a shaking tree -- (engine/events/field_moves.asm, right after its WaitSFX). local SFX_SANDSTORM = 0x6d -- The field moves' own sounds, by their index in sfxOrder: -- SFX_STRENGTH MovementFunction_Strength, as the boulder goes -- SFX_PLACE_PUZZLE_PIECE_DOWN OWCutAnimation, the snip -- SFX_SURF PlayWhirlpoolSound, which is a bare SFX_SURF -- SFX_BUBBLEBEAM Script_UsedWaterfall's playsound -- SFX_FLASH UseFlashTextScript's text_asm local SFX_STRENGTH = 27 local SFX_PLACE_PUZZLE_PIECE_DOWN = 30 local SFX_BUBBLEBEAM = 81 local SFX_SURF = 83 local SFX_FLASH = 169 -- EMOTE_SHOCK, emote 0 in constants/script_constants.asm. Script_FishCastRod -- loads it over EMOTE_ROD, so the bubble that pops on a bite is the shock one. local EMOTE_SHOCK = 0 -- GetWarpSFX (home/map.asm) picks one of three by the tile the player is -- standing on when the warp is taken; these are their sfxOrder indices in this -- cache (Sfx_EnterDoor, Sfx_WarpTo, Sfx_ExitBuilding), resolved by NAME at the -- call site so a cache with a different table still finds them. local SFX_ENTER_DOOR = 31 local SFX_WARP_TO = 19 local SFX_EXIT_BUILDING = 35 local SFX_JUMP_OVER_LEDGE = 0x16 local World = {} World.__index = World -- Movement direction → map.connections key. local DIR_CONN = { up = "north", down = "south", left = "west", right = "east" } local FACING_ID = { down = 0, up = 1, left = 2, right = 3 } local NEIGHBOR_HOPS = 2 -- constants/script_constants.asm local VAR_FACING = 0x09 -- VAR_WEEKDAY, whose .DayOfWeek arm is `call GetWeekday` -> wCurDay. 39 of the -- 40 `readvar` sites reachable from a map callback are this one: it is what -- decides which of the seven travelling siblings is standing on their route, -- which haircut brother is in, and which day the Goldenrod underground -- MAPCALLBACK_OBJECTS lets through. local VAR_WEEKDAY = 0x0b -- VAR_BATTLETYPE, the one VAR_* slot a script writes that anything reads back: -- `writevar VAR_BATTLETYPE / loadvar BATTLETYPE_FORCEITEM` is what makes Lugia, -- Ho-Oh and the Red Gyarados hold their item, FORCESHINY what makes the -- Gyarados red, and CANLOSE what lets the Cherrygrove rival beat you. local VAR_BATTLETYPE = 0x03 -- constants/battle_constants.asm BATTLETYPE_FORCEITEM: InitEnemyMon's -- `.WildItem` reads wBaseItem1 unconditionally for this type instead of -- rolling the ordinary 25%/8% chance, which is how Ho-Oh's SACRED_ASH (and -- Lugia's, and the Red Gyarados' held item) is guaranteed rather than random. local BATTLETYPE_FORCEITEM = 10 -- constants/battle_constants.asm BATTLETYPE_FORCESHINY: the Lake of Rage -- Gyarados. InitEnemyMon's `.NotRoaming` arm (engine/battle/core.asm:5876) -- swaps the rolled DVs for ATKDEFDV_SHINY $EA / SPDSPCDV_SHINY $AA, and -- TryToRunAwayFromBattle refuses to run for this type, which the battle -- reads off opts.battleType. local BATTLETYPE_FORCESHINY = 7 -- constants/battle_constants.asm BATTLETYPE_CANLOSE: the Cherrygrove rival's -- three arms are the only `loadvar VAR_BATTLETYPE, BATTLETYPE_CANLOSE` in the -- game. LostBattle (engine/battle/core.asm) answers this type by sliding the -- winner's pic in and printing the loss text, then RETURNS -- no grayscale, no -- whiteout -- and the script that armed it follows `startbattle` with a bare -- `reloadmap`, never `reloadmapafterbattle`, so Script_BattleWhiteout is -- unreachable from this battle on either path. local BATTLETYPE_CANLOSE = 1 local VAR_PARTYCOUNT = 0x01 local VAR_BATTLERESULT = 0x02 local VAR_TIMEOFDAY = 0x04 local VAR_DEXCAUGHT = 0x05 local VAR_DEXSEEN = 0x06 local VAR_BADGES = 0x07 local VAR_MOVEMENT = 0x08 local VAR_HOUR = 0x0a local VAR_MAPGROUP = 0x0c local VAR_MAPNUMBER = 0x0d local VAR_UNOWNCOUNT = 0x0e local VAR_ENVIRONMENT = 0x0f local VAR_BOXSPACE = 0x10 local VAR_CONTESTMINUTES = 0x11 local VAR_XCOORD = 0x12 local VAR_YCOORD = 0x13 local VAR_SPECIALPHONECALL = 0x14 -- wPlayerState (constants/ram_constants.asm) as VAR_MOVEMENT reads it raw: -- NORMAL 0, BIKE 1, SKATE 2, SURF 4, SURF_PIKA 8. FieldMoves only models the -- four states the port can actually enter; PLAYER_SKATE is written by nothing -- in Gold. local PLAYER_STATE_ID = { [FieldMoves.PLAYER_NORMAL] = 0, [FieldMoves.PLAYER_BIKE] = 1, [FieldMoves.PLAYER_SURF] = 4, [FieldMoves.PLAYER_SURF_PIKA] = 8, } -- The same table backwards, for `loadvar VAR_MOVEMENT, PLAYER_BIKE`: the mount -- and the dismount are a variable write on the cart, so writeVar has to be -- able to turn one back into a state name. local PLAYER_STATE_BY_ID = {} for state, id in pairs(PLAYER_STATE_ID) do PLAYER_STATE_BY_ID[id] = state end -- constants/collision_constants.asm, for GetWarpSFX below. local COLL_DOOR = 0x71 local COLL_WARP_PANEL = 0x7c -- constants/sprite_constants.asm: wVariableSprites is indexed from SPRITE_VARS, -- so an object whose `sprite` is one of $f0..$fc names a SLOT rather than a -- sheet and only `variablesprite` can say what stands there. local SPRITE_VARS = 0xf0 -- constants/sprite_constants.asm:143-145. Neither byte names a sheet: GetMonSprite -- (engine/overworld/overworld.asm:279-305) tests them BEFORE the SPRITE_VARS -- range and answers with LoadOverworldMonIcon of wBreedMon1Species / -- wBreedMon2Species, i.e. the deposited mon's own party-menu icon. Route 34's -- two yard objects carry them. local SPRITE_DAY_CARE_MON_1 = 0xe0 local SPRITE_DAY_CARE_MON_2 = 0xe1 -- constants/engine_flags.asm, const_def, with the five-wide pokegear block -- first. These three are bits of wDayCareMan / wDayCareLady rather than slots -- of their own (data/events/engine_flags.asm:18-20), so World:engineFlag reads -- them straight out of save.dayCare. local ENGINE_DAY_CARE_MAN_HAS_EGG = 5 local ENGINE_DAY_CARE_MAN_HAS_MON = 6 local ENGINE_DAY_CARE_LADY_HAS_MON = 7 -- constants/map_setup_constants.asm (const_def $f1). The byte picks a row of -- MapSetupScripts (data/maps/setup_scripts.asm); the port has one map load, so -- what survives of each script is which fades it is bracketed by. local MAPSETUP_WARP = 0xf1 local MAPSETUP_CONTINUE = 0xf2 local MAPSETUP_RELOADMAP = 0xf3 local MAPSETUP_TELEPORT = 0xf4 local MAPSETUP_DOOR = 0xf5 local MAPSETUP_FALL = 0xf6 local MAPSETUP_CONNECTION = 0xf7 local MAPSETUP_LINKRETURN = 0xf8 local MAPSETUP_TRAIN = 0xf9 local MAPSETUP_SUBMENU = 0xfa local MAPSETUP_BADWARP = 0xfb -- Which of the eleven setup scripts fades, read off data/maps/setup_scripts.asm -- with its FALLTHROUGHS honoured -- MapSetupScript_Fall drops into _Door, which -- drops into _Train, and _Teleport drops into _Warp, so FALL fades out because -- DOOR's FadeOutToWhite is the next command and not because FALL names one. -- -- fade out then in : DOOR, FALL, TELEPORT (a FadeOutToWhite opens the list) -- fade in only : WARP, BADWARP, TRAIN, LINKRETURN, CONTINUE, RELOADMAP -- neither : CONNECTION, SUBMENU (an edge cross must not hitch) local MAPSETUP_FADE_OUT = { [MAPSETUP_DOOR] = true, [MAPSETUP_FALL] = true, [MAPSETUP_TELEPORT] = true, } local MAPSETUP_FADE_IN = { [MAPSETUP_DOOR] = true, [MAPSETUP_FALL] = true, [MAPSETUP_TELEPORT] = true, [MAPSETUP_WARP] = true, [MAPSETUP_BADWARP] = true, [MAPSETUP_TRAIN] = true, [MAPSETUP_LINKRETURN] = true, [MAPSETUP_CONTINUE] = true, [MAPSETUP_RELOADMAP] = true, } -- MapSetupScript_Connection and _Submenu are the two with no FadeInFromWhite; -- naming them keeps the table above readable as the whole eleven-row set. local MAPSETUP_NO_FADE = { [MAPSETUP_CONNECTION] = true, [MAPSETUP_SUBMENU] = true, } -- MapSetupCommands $26 UpdateRoamMons and $27 JumpRoamMons, read off the same -- eleven scripts with the same fallthroughs honoured. This is the ONLY thing -- that moves the three legendary beasts around Johto, and where each sits in -- its script decides which map "the player's map" means: -- -- UpdateRoamMons is the tail of MapSetupScript_Train, and _Fall drops into -- _Door drops into _Train -- so a door warp, a fall and a train ride all -- nudge each beast one connection along. _Connection names it too. It runs -- AFTER the load, so the map it avoids putting them on is the NEW one. -- -- JumpRoamMons is the third row of MapSetupScript_Teleport, BEFORE that -- script falls into _Warp -- so it runs before the load, and it scatters -- every beast to a random roam map. Flying across Johto shuffles them; -- walking through a door does not. -- -- A plain MAPSETUP_WARP names neither, which is why warping between two floors -- of a building leaves them where they were. local MAPSETUP_ROAM_UPDATE = { [MAPSETUP_CONNECTION] = true, [MAPSETUP_DOOR] = true, [MAPSETUP_FALL] = true, [MAPSETUP_TRAIN] = true, } local MAPSETUP_ROAM_JUMP = { [MAPSETUP_TELEPORT] = true } -- FadeOutToWhite / FadeInFromWhite (engine/tilesets/timeofday_pals.asm) are -- `ld b, $4` steps of ConvertTimePalsIncHL / .DecHL, each followed by -- `DelayFrames 2`: four steps, eight frames, per half. The port draws the -- overworld with its colours already baked in and has no four-entry palette -- left to rotate, so the four steps become four levels of the flat sheet -- World:draw already holds for the fade specials. local FADE_STEPS = 4 local FADE_STEP_FRAMES = 2 -- A New Game starts in the bedroom, not outside: engine/menus/intro_menu.asm -- NewGame sets wDefaultSpawnpoint = SPAWN_HOME and warps there, and -- data/maps/spawn_points.asm puts SPAWN_HOME at PLAYERS_HOUSE_2F (3,3). -- landmarks.lua carries the real table; these are the fallback for a cache -- imported before the spawn table existed. local SPAWN_HOME = "SPAWN_HOME" local START_MAP = "PLAYERS_HOUSE_2F" local START_X, START_Y, START_FACING = 3, 3, "down" local PLAYER_SPRITE = "SPRITE_CHRIS" -- constants/event_flags.asm. HatchEggs sets this one by hand, for exactly one -- species, right after SetSeenAndCaughtMon. wEventFlags is keyed by NUMBER -- here (that is what the extractor emits), so the constant lives at the call -- site rather than as a string the Events store would refuse to compare. local EVENT_TOGEPI_HATCHED = 84 -- The last flag InitializeEventsScript sets (engine/events/std_scripts.asm), -- and the one PlayersHouse2FInitializeRoomCallback checks before jumping to -- it: once it is set the seed never runs again for that save file. Numbered -- for the same reason as the one above. local EVENT_INITIALIZED_EVENTS = 54 -- SPRITEMOVEDATA_STRENGTH_BOULDER, $19 in constants/map_object_constants.asm. -- The push looks for this on the object, not for SPRITE_BOULDER: the sprite is -- shared with the immovable scenery rocks and the movement data is what tells -- the two apart (data/sprites/map_objects.asm gives only this row the -- STRENGTH_BOULDER palette flag .CheckStrengthBoulder tests). local SPRITEMOVEDATA_STRENGTH_BOULDER = 0x19 -- Script_UsedStrength's `pause 3` between the cry and MoveBoulderText, and -- Script_UsedWaterfall's per-cell climb; both are frames at 60 Hz. local STRENGTH_PAUSE_FRAMES = 3 -- The four field-move / field-item strings this file prints, in the port's own -- TextBox markers (\n = second line, \f = page break, {STRBUF} = the shared -- wStringBuffer2 token, which TextBox fills from game.stringBuffer). -- -- They are transcribed rather than looked up because nothing in the ROM's -- script bytecode points at them: engine/events/overworld.asm names each label -- directly, so the extractor -- which only walks reachable script pointers -- -- never sees them and data/generated/text.lua has no key for them. Bodies -- from data/text/common_2.asm; `#` is the four-tile POKé compression byte, so -- "POKéMON" here is the same seven tiles the cart draws. -- -- Strings.source marks them for the catalog generator without translating -- them here: the table is built at require time and Strings.load has no -- catalog yet, so the lookup happens at the showText call sites below -- -- the same arrangement src/world/gen2/FieldMoves.lua uses for the other -- twenty-odd lines engine/events/overworld.asm names directly. local TEXT_ASK_HEADBUTT = Strings.source( "A POKéMON could be\nin this tree.\fWant to HEADBUTT\nit?") local TEXT_USE_HEADBUTT = Strings.source("{STRBUF} did a\nHEADBUTT!") local TEXT_HEADBUTT_NOTHING = Strings.source("Nope. Nothing…") local TEXT_ROD_BITE = Strings.source("Oh!\nA bite!") local TEXT_ROD_NOTHING = Strings.source("Not even a nibble!") -- _UseSweetScentText / _SweetScentNothingText (data/text/common_2.asm). -- GetPartyNickname leaves the same name in wStringBuffer1-3, so {STRBUF} -- (wStringBuffer2) reads back the text_ram wStringBuffer3 line just as well. local TEXT_USE_SWEET_SCENT = Strings.source("{STRBUF} used\nSWEET SCENT!") local TEXT_SWEET_SCENT_NOTHING = Strings.source("Looks like there's\nnothing here…") -- _UseSacredAshText (data/text/common_2.asm), hung off SacredAshScript's own -- `text_far` and never reached through a script pointer the extractor walks, -- same reason the itemfinder pair above needs Strings.source instead of a -- text.lua key. local TEXT_USE_SACRED_ASH = Strings.source( "{PLAYER}'s POKéMON\nwere all healed!") -- CheckHeadbuttTreeTile (home/map_objects.asm) compares the facing tile's -- collision against COLL_HEADBUTT_TREE and its unused $1d alias -- (constants/collision_constants.asm). Both sit at permission $0f, so a tree -- blocks a step like any other wall and only the A press tells them apart -- -- which is why this is a collision test and not a tile-graphic one. Same -- shape as Permissions.isGrass, and it belongs beside it. local HEADBUTT_TREE = { [0x15] = true, [0x1d] = true } -- TryHeadbuttOW's `ld d, HEADBUTT / call CheckPartyMove`; moves.lua keys the -- move by its own constant name. local MOVE_HEADBUTT = "HEADBUTT" -- The three fishing rods, keyed the way items.lua keys them so a call site -- names an item rather than a byte. Indices from constants/item_constants.asm -- (OLD_ROD $3a, GOOD_ROD $3b, SUPER_ROD $3d); data/items/attributes.asm gives -- all three KEY_ITEM, ITEMMENU_CLOSE in the field and ITEMMENU_NOUSE in -- battle, i.e. a rod is a field-only item and the battle PACK must refuse it. local ROD_INDEX = { OLD_ROD = 0x3a, GOOD_ROD = 0x3b, SUPER_ROD = 0x3d } -- RepelEffect / SuperRepelEffect / MaxRepelEffect (engine/items/item_effects.asm): -- each is a bare `ld b, ` into the shared UseRepel, so the step count is -- the only thing that differs between the three items. wRepelEffect is -- save.repelSteps, ticked down by StepEvents.repelStep on every footfall. local REPEL_STEPS = { REPEL = 100, SUPER_REPEL = 200, MAX_REPEL = 250 } -- NormalBoxEffect / GorgeousBoxEffect: the item -> DECOFLAG_* it opens on. -- The pairing is crossed on purpose -- the NORMAL BOX holds the SILVER trophy -- and the GORGEOUS BOX the GOLD one, exactly as the asm reads. local TROPHY_BOXES = { NORMAL_BOX = Decorations.DECOFLAG_SILVER_TROPHY_DOLL, GORGEOUS_BOX = Decorations.DECOFLAG_GOLD_TROPHY_DOLL, } -- Script_FishCastRod ends on `pause 40`, and Script_GotABite pauses another 40 -- over the bobbing rod before the text lands. ShakeHeadbuttTree counts down -- wFrameCounter from 32. All three are frames at 60 Hz, which is the same -- clock World:step runs on. local FISH_CAST_FRAMES = 40 local FISH_BITE_FRAMES = 40 local HEADBUTT_SHAKE_FRAMES = 32 -- The vanilla links the two encounter chains wrap (World:rollEncounter, below), -- hoisted here so an empty chain allocates no closure and so every caller -- -- the step roll, `randomwildmon`, the Bug Contest and SWEET SCENT -- can see -- them. Each takes the piped value (the wild tables the roll is made against) -- and the ctx, and answers { species, level } or nil. local function rollGrassVanilla(tables, ctx) return Encounter.grassSlot(tables, ctx.mapId, ctx.daytime, nil) end local function rollWaterVanilla(tables, ctx) return Encounter.waterSlot(tables, ctx.mapId, nil) end local function rollContestVanilla(_, ctx) return BugContest.chooseWild(ctx.data) end local function sameEncounter(enc) return enc end -- The encounter.fishing chain's vanilla link. Encounter.fishSlot's own rod -- mapping, and then Encounter.fish over whatever candidate row the chain handed -- down -- routed back through Encounter.fish rather than re-rolled here, so a -- mod that only inspected the list gets the cart's cumulative-chance walk -- (engine/events/fish.asm Fish) byte for byte. local FISH_ROD_KEY = { OLD_ROD = "old", GOOD_ROD = "good", SUPER_ROD = "super" } local function fishVanilla(rod, _mapId, candidates) if not candidates then return nil end return Encounter.fish({ fishGroups = { hooked = candidates } }, "hooked", FISH_ROD_KEY[rod] or rod or "old", nil) end local function speciesByIndex(pokemon, index) if not pokemon or not index then return nil end for id, def in pairs(pokemon) do if type(def) == "table" and def.index == index then return id, def end end return nil end local function itemByIndex(items, index) if not items or not index or index == 0 then return nil end for id, def in pairs(items) do if type(def) == "table" and def.index == index then return id, def end end return nil 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) if not flags then return 0 end local n = 0 for _, has in pairs(flags) do if has then n = n + 1 end end return n end -- `givepoke` builds the same record a caught wild mon does, through -- src/battle/gen2/Mon.lua. It used to go through Gen 1's Pokemon.new with a -- hand-adapted base-stat block, and that adapter handed it `level1Moves = {}` -- and `learnset = {}` -- fields the Gen 2 extractor does not write, because a -- Gen 2 moveset lives in `levelMoves` (EvosAttacks). So every scripted gift, -- the STARTER included, arrived knowing nothing: FIGHT listed no moves and the -- battle had no legal action left in it. local function givePokeMon(data, speciesIndex, level, itemIndex) local id = speciesByIndex(data.pokemon, speciesIndex) if not id then return nil end return Mon.new(data, id, level or 5, { item = itemIndex and itemIndex ~= 0 and itemByIndex(data.items, itemIndex) or nil, }) end local function loadGenerated(path) local chunk, err = love.filesystem.load(path) if not chunk then return nil, err end local ok, value = pcall(chunk) if not ok then return nil, value end return value end -- Paste the 9-tile roof sheet over atlas tiles $0a-$12. local function applyRoofOverlay(atlasPath, roofPath, tilesPerRow) -- Assets.resolve, not the raw path: an overrides/ file or an AssetTransform -- output has to feed the derivation rather than be bypassed by it. local atlasData = love.image.newImageData(Assets.resolve(atlasPath)) local roofData = love.image.newImageData(Assets.resolve(roofPath)) for t = 0, 8 do local destId = 0x0a + t local dx = (destId % tilesPerRow) * 8 local dy = math.floor(destId / tilesPerRow) * 8 local sx = t * 8 for y = 0, 7 do for x = 0, 7 do local r, g, b, a = roofData:getPixel(sx + x, y) atlasData:setPixel(dx + x, dy + y, r, g, b, a) end end end local image = love.graphics.newImage(atlasData) image:setFilter("nearest", "nearest") return image end -- Gen 2 connections use mapId (name); offsets are in blocks (32 px), -- same strip math as Gen 1 OverworldState.computeNeighbors. function World.computeNeighbors(maps, rootId, hops, reachW, reachH) local out = {} local rootDef = maps[rootId] if not rootDef then return out end local placed = { [rootId] = true } local queue = { { def = rootDef, ox = 0, oy = 0, hops = 0 } } local qi = 1 local function inReach(def, ox, oy) if not (reachW and reachH and rootDef) then return false end return ox + def.width * 32 > -reachW and ox < rootDef.width * 32 + reachW and oy + def.height * 32 > -reachH and oy < rootDef.height * 32 + reachH end while queue[qi] do local cur = queue[qi] qi = qi + 1 for dir, conn in pairs(cur.def.connections or {}) do local destId = conn.mapId or conn.map local destDef = type(destId) == "string" and maps[destId] or nil if destDef and not placed[destId] then placed[destId] = true local offset = conn.offset or 0 if offset == 0 then offset = 0 end -- squash signed-zero local ox, oy if dir == "north" then ox, oy = offset * 32, -destDef.height * 32 elseif dir == "south" then ox, oy = offset * 32, cur.def.height * 32 elseif dir == "west" then ox, oy = -destDef.width * 32, offset * 32 else ox, oy = cur.def.width * 32, offset * 32 end ox, oy = cur.ox + ox, cur.oy + oy if cur.hops + 1 <= hops or inReach(destDef, ox, oy) then table.insert(out, { id = destId, ox = ox, oy = oy }) if cur.hops + 1 < hops or inReach(destDef, ox, oy) then table.insert(queue, { def = destDef, ox = ox, oy = oy, hops = cur.hops + 1, }) end end end end end return out end function World.new(game) local self = setmetatable({ game = game, status = nil, maps = nil, tilesets = nil, roofs = nil, sprites = nil, scripts = nil, text = nil, constants = nil, events = Events.new(), mapScenes = {}, -- [mapId] = sceneId -- wCmdQueue: four slots, refilled from the map's MAPCALLBACK_CMDQUEUE on -- every load and polled once a frame (engine/overworld/cmd_queue.asm). cmdQueue = CmdQueue.new(), vm = nil, map = nil, player = nil, mapImage = nil, mapImages = {}, -- _AnimateTileset's two counters (engine/tilesets/tileset_anims.asm:11, -- :57), plus the per-bake cell lists and palettes the frames draw with. animClock = 0, animTimer = 0, animCells = {}, bgSets = {}, neighbors = {}, atlasCache = {}, npcPool = {}, npcs = {}, ghosts = {}, entities = {}, talkNpc = nil, camera = Camera.new(), heldDir = nil, -- wPlayerTurningDirection's useful half: the direction a STEP latched via -- .FinishFacing. CheckStandingOnIce + .CheckForced re-inject it as the -- d-pad while the tile underfoot is COLL_ICE, which is the whole ice-slide -- rule (engine/overworld/player_movement.asm). Nil means "standing". turningDirection = nil, viewW = 160, viewH = 144, moveState = nil, lastSfx = nil, pokePic = nil, pendingSceneScript = false, -- GBC color state (engine/gfx/color.asm). `daytime` is the resolved -- MORN/DAY/NITE/DARK the map is currently lit by; clockHour overrides -- World:hour for drivers and tests, so the palette, the hour windows and -- VAR_HOUR all move together; flashUsed lifts PALETTE_DARK maps. palettes = nil, daytime = nil, clockHour = nil, -- wCurDay, SUNDAY 0 .. SATURDAY 6, when something wants to pin it; nil -- reads the host clock. See World:weekday. clockDay = nil, flashUsed = false, paletteClock = 0, -- FlickeringCaveEntrancePalette's frame counter: only a DARKNESS_PALSET -- map reads it, and only to decide which of two baked canvases is up. flickerClock = 0, flickerPhase = 1, -- wPlayerState (constants/ram_constants.asm), as FieldMoves names it. playerState = FieldMoves.PLAYER_NORMAL, -- BIKEFLAGS_STRENGTH_ACTIVE_F. ResetBikeFlags clears the whole byte on -- every map load, which is why STRENGTH has to be used again in the next -- room. strengthActive = false, -- STATUSFLAGS_NO_WILD_ENCOUNTERS_F, driven by the `wildoff` / `wildon` -- script commands. noWildEncounters = false, -- Blocks CUT and WHIRLPOOL have swapped out on the loaded map, as -- { mapId = { [index] = original } }. The cart edits wOverworldMapBlocks, -- a BUFFER, and LoadMapAttributes refills it from ROM on every map load -- -- which is why a cut tree is back the next time you walk in. Restoring -- these at the top of setMap is that refill. blockEdits = {}, -- engine/overworld/map_setup.asm:78 objectSpawns = {}, -- A field move that is mid-flow (the used-X text, then its effect). fieldMove = nil, -- ---- state the script VM owns ------------------------------------------ -- wVariableSprites (ram/wram.asm), indexed from SPRITE_VARS: slot -> plain -- OverworldSprites byte. Cleared on a map load the way the cart's copy is -- not -- it is real WRAM that survives -- so this one survives too, and -- every map that needs a slot filled sets it from its own scene script. variableSprites = {}, -- The VAR_* slots `writevar` / `loadvar` write. Only VAR_BATTLETYPE is -- read back today, by the next startbattle. scriptVars = {}, -- WarpCheck's find. A script that ends standing on a warp tile must not -- warp INSIDE the command: the commands queued behind it would run with the -- map pulled out from under them, so the destination waits here until -- World:step sees the VM go idle. pendingWarp = nil, -- ShakeScreen's live wPlayerStepVectorY offset (`earthquake`). shake = nil, -- wDontPlayMapMusicOnReload: one shot, consumed by the next map reload. dontRestartMusic = false, -- FadeOutToWhite / FadeOutToBlack's sheet, until a FadeInFrom* lifts it. fade = nil, -- A `musicfadeout` whose ramp still has frames left, plus the label queued -- underneath it. pendingMusic = nil, showDebugHud = os.getenv("POKEPORT_DEV") == "1", }, World) -- ow.runner under the Gen 1 name (src/world/OverworldController.lua:216). -- A mod guards with `ow.runner and ow.runner:isRunning()` before acting; -- nil there is FALSEY, so the mod concludes no script is running while one -- is and acts mid-cutscene. Gold's frame is self.vm, so this is the query -- half of it and nothing else. self.runner = setmetatable( { isRunning = function() return self:scriptRunning() end }, { __index = function(_, key) if key == "vm" then return self.vm end if key == "co" then return self.vm and self.vm.co end if key == "ctx" then return self.vm and self.vm.ctx end return nil end }) return self end -- LoadPlayerData (engine/menus/save.asm) copies sPlayerData straight back over -- wPlayerData, and ALL THREE of the things this restores live inside that -- region (ram/wram.asm): wEventFlags, the block of wSceneID bytes above -- it, and wPlayerState below them. -- That is why a cartridge reload comes back with every flag the player set and -- with each map still on the scene it had been advanced to. MeetMomScript -- (maps/PlayersHouse1F.asm) needs both halves at once: it ends on `setscene -- SCENE_PLAYERSHOUSE1F_NOOP` plus a setevent/clearevent pair, so talk to MOM, -- save, reload, and she must NOT play her first-time scene again. -- -- `save.events` is the serialized bitfield src/world/gen2/Events.lua writes -- (byte index -> byte value, keyed by NUMBER) and `save.mapScenes` is map id -> -- scene id; src/core/gen2/Save.lua has already scrubbed both by the time this -- runs. The restore REPLACES the seed rather than merging with it: a script -- that CLEARED one of InitializeEventsScript's flags (MeetMomScript clears -- EVENT_PLAYERS_HOUSE_MOM_2 on its way out) has to stay cleared across a -- reload, and an OR would set it again on every boot. -- -- Called from World:load BEFORE the first setMap, because the object list is -- only re-read when a map loads (RefreshMapSprites): a flag restored after the -- load would leave the wrong people standing on the first map until the player -- walked into the next one. function World:loadPlayerData(save) self.events = Events.new() self.mapScenes = {} if type(save) == "table" then if type(save.events) == "table" then self.events:restore(save.events) end if type(save.mapScenes) == "table" then for mapId, scene in pairs(save.mapScenes) do self.mapScenes[mapId] = tonumber(scene) or 0 end end end -- PlayersHouse2FInitializeRoomCallback (maps/PlayersHouse2F.asm) jumps to -- InitializeEventsScript only while EVENT_INITIALIZED_EVENTS is still clear, -- and the script sets that flag last. So a save that has never had the seed -- gets it here -- a brand new game, or one whose world never ran -- and a -- save that has keeps exactly the bitfield it was written with. if not self.events:get(EVENT_INITIALIZED_EVENTS) then for _, id in ipairs(self.initialEvents or {}) do self.events:set(id, true) end for _, id in ipairs(self.initialEngineFlags or {}) do self:setEngineFlag(id, true) end end -- Variable sprites: seed any slot the save does not already carry. -- -- Seeding only on a fresh game would be the cart's own behaviour, and the -- cart gets away with it because wVariableSprites is WRAM that is never -- reloaded mid-session. This port rebuilds the World on every CONTINUE, so -- "only on a fresh game" would mean an empty slot -- and an empty slot is an -- object that does not spawn. Filling only what is missing keeps a later -- `variablesprite` (Route 36 swaps the tree for a TWIN once Sudowoodo is -- beaten, reusing the same slot) intact across a save and reload. local saved = (self.game and self.game.save and self.game.save.variableSprites) if type(saved) == "table" then for slot, sprite in pairs(saved) do if type(slot) == "number" then self.variableSprites[slot] = sprite end end end for _, row in ipairs(self.initialSprites or {}) do if row.slot and row.sprite and not self.variableSprites[row.slot] then self.variableSprites[row.slot] = row.sprite end end -- wPlayerState, the third member of the block. Everything the state decides -- follows from this one field: UpdatePlayerSprite picks the sheet off it, -- .DoStep picks STEP_BIKE or STEP_WALK off it, and .TranslateIntoMovement -- picks .CheckLandPerms or .CheckSurfPerms off it -- so a save made on the -- BICYCLE that came back on foot was not just wearing the wrong sprite, it -- was walking at the wrong speed over a different set of tiles. -- -- Only a name Save.PLAYER_STATES vouches for is taken; anything else is -- PLAYER_NORMAL, which is where Save.validate has already put it and is the -- cart's own zero byte. applyPlayerState rather than a bare assignment so a -- world that already has a player repaints them on the spot; on the boot -- path there is no player yet and the first setMap's CheckUpdatePlayerSprite -- is what puts the sprite on. local state = type(save) == "table" and save.playerState or nil self:applyPlayerState(Gen2Save.PLAYER_STATES[state] and state or nil) -- wBackupWarpNumber / wBackupMapGroup / wBackupMapNumber, the triple a -1 -- warp destination resolves through (home/map.asm CopyWarpData). On the -- cart it sits in the same saved WRAM block as wPlayerState, so a save made -- on POKECENTER_2F still knows which centre's stairs lead back down after a -- reload; this port rebuilds the World on every CONTINUE, so the triple has -- to ride the save the same way wPlayerState does. local backup = type(save) == "table" and save.backupWarp or nil if type(backup) == "table" and backup.map and backup.warp then self.backupWarp = { warp = backup.warp, map = backup.map } end return self.events end -- The Gen 2 content tables come off game.data rather than off disk. -- src/core/Game2.lua:load reads every one of them into self.data BEFORE it -- calls mods:load(self.data), so what this hands back is the merged table: a -- registry that targets data.gen2Maps has somewhere to write, and the world -- walks what it wrote. Held by reference on purpose: a copy here would -- silently un-merge every one of them. -- -- The on-disk fallback covers a World built without a Game2 behind it: -- the stub-game worlds in tests/gen2_*_test.lua set the fields they need -- straight onto the world and never call :load, but a driver or tool that does -- call it with a bare game table still has to boot. Nothing merges mods on -- that path, so reading the cache directly reaches the same table by another -- route; the read is cached back into game.data so a later reader (MartMenu's -- data.gen2Marts, Pokegear's data.gen2Landmarks) sees the same one table. function World:dataTable(key, path) local data = self.game and self.game.data local held = data and data[key] if held ~= nil then return held end local value, err = loadGenerated(path) if value ~= nil and data then data[key] = value end return value, err end function World:load() local maps, mapsErr = self:dataTable("gen2Maps", "data/generated/maps.lua") local tilesets, tilesErr = self:dataTable("gen2Tilesets", "data/generated/tilesets.lua") if not maps or not tilesets then self.status = Strings("Gold cache incomplete:\n%s", tostring(mapsErr or tilesErr)) return false end self.maps = maps self.tilesets = tilesets self.roofs = self:dataTable("gen2Roofs", "data/generated/roofs.lua") self.sprites = self:dataTable("gen2Sprites", "data/generated/sprites.lua") -- A cache from before the palette stage existed simply has no palettes.lua; -- everything below falls back to the grayscale path rather than failing. self.palettes = self:dataTable("gen2Palettes", "data/generated/palettes.lua") -- Town-map landmarks for the Pokegear, and the SPAWN_* table that decides -- where a New Game and every Pokecenter respawn start. self.landmarks = self:dataTable("gen2Landmarks", "data/generated/landmarks.lua") self.encounters = self:dataTable("gen2Encounters", "data/generated/encounters.lua") self.stdScripts = self:dataTable("gen2StdScripts", "data/generated/std_scripts.lua") self.trainers = self:dataTable("gen2Trainers", "data/generated/trainers.lua") -- data.trainers is the second name the Gen 2 code reads this same table by -- (World:trainerParty, BugContest, Palettes.trainerPalette). The two keys -- are aliased in Game2 before the merge; this line only has to catch the -- fallback path above, and assigns the same reference either way. if self.game and self.game.data and self.trainers then self.game.data.trainers = self.trainers end -- Mart shelves (data/items/marts.asm). A cache from before the mart stage -- has no marts.lua at all; MartMenu treats that as an empty shelf rather -- than inventing stock, so a clerk still opens and still says his lines. self.marts = self:dataTable("gen2Marts", "data/generated/marts.lua") -- showemote's bubbles. Loaded here rather than at draw time so a missing -- sheet (a cache from before the emote stage) just means no bubble. local menuGfx = self:dataTable("gen2MenuGfx", "data/generated/menu_gfx.lua") local emotes = menuGfx and menuGfx.emotes if emotes then self.emoteOrder = emotes.order self.emoteImages = {} for _, key in ipairs(emotes.order or {}) do local path = emotes[key] if path then local okImg, img = pcall(Assets.image, path) if okImg then self.emoteImages[key] = img end end end -- ShakeGrass' one tile (data/sprites/emotes.asm:22). A cache from before -- it was extracted simply has no rustle. if emotes.grassRustle then local okImg, img = pcall(Assets.image, emotes.grassRustle) if okImg then self.grassRustleImage = img end end end -- The heal machine's two OBJ tiles and their CGB palette, for the -- Pokecenter light show (World:startHealMachineAnim). A cache from before -- the sheet existed just has no entry, and the anim degrades to its sounds. local healMachine = menuGfx and menuGfx.healMachine if healMachine and healMachine.sheet then local okImg, img = pcall(Assets.image, healMachine.sheet) if okImg then self.healMachineImage = img self.healMachinePalette = healMachine.palette end end -- POKEPORT_GOLD_HOUR pins the clock so a driver's screenshots are stable and -- a reviewer can look at any time of day on demand. local forcedHour = tonumber(os.getenv("POKEPORT_GOLD_HOUR") or "") if forcedHour then self.clockHour = forcedHour end -- POKEPORT_GOLD_DAY pins wCurDay (SUNDAY 0 .. SATURDAY 6) for the same -- reason: the day-of-week map callbacks put a different NPC on seven routes, -- and a driver that only ever sees today's cannot check the other six. local forcedDay = tonumber(os.getenv("POKEPORT_GOLD_DAY") or "") if forcedDay then self.clockDay = forcedDay end self.scripts = self:dataTable("gen2Scripts", "data/generated/scripts.lua") or {} self.text = self:dataTable("gen2Text", "data/generated/text.lua") or {} self.constants = self:dataTable("gen2Constants", "data/generated/constants.lua") or {} -- 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 -- events.lua at all, so every reader treats it as optional. self.eventTables = self:dataTable("gen2EventTables", "data/generated/events.lua") or {} -- Take the phone book off the cache when it has one; src/core/gen2/Phone.lua -- keeps its transcribed tables as the fallback for an older cache. require("src.core.gen2.Phone").useExtracted(self.eventTables) -- data.pokemon and data.items keep the SHARED Gen 1 keys: both registries -- route to their Gen 1 target under Gen 2, so Game2 already has them -- loaded and merged. Going through dataTable is what makes that hold -- -- the old unconditional re-read overwrote both with a fresh copy off the -- cache and threw away every `pokemon` and `items` merge a mod had made. self:dataTable("pokemon", "data/generated/pokemon.lua") self:dataTable("items", "data/generated/items.lua") if self.game and self.game.save then local save = self.game.save save.party = save.party or {} save.inventory = save.inventory or {} save.phoneContacts = save.phoneContacts or {} end -- Retail Gold hides story NPCs (lab cop, rivals, etc.) via -- InitializeEventsScript's setevent list -- apply before spawning people. local initial = self:dataTable("gen2InitialEvents", "data/generated/initial_events.lua") self.initialEvents = (initial and initial.flags) or {} self.initialEngineFlags = (initial and initial.engineFlags) or {} -- InitializeEventsScript does not only `setevent`. It ends with nine -- `variablesprite` assignments, and SPRITE_WEIRD_TREE ($f4) and friends are -- wVariableSprites SLOTS rather than sheets -- so until the slot is filled, -- World:resolveSprite answers nil and World:pooledNpc spawns NOTHING. The -- seed used to carry the setevent list alone, which meant the Sudowoodo on -- Route 36 was simply absent from a new game, and with it TM08 ROCK SMASH, -- the Burned Tower, Morty, FOGBADGE and SURF; likewise the Olivine rival, -- the Azalea Rocket, the four Fuchsia Gym Janines, the Copycat and the -- Janine impersonator. -- -- The extractor now writes them (`sprites`). The fallback finds the same -- script in scripts.lua for a cache written before it did -- the script is -- the one whose setevent ids ARE this flag list -- so an existing cache does -- not have to be rebuilt for the tree to come back. self.initialSprites = (initial and initial.sprites) or self:findInitialSprites() -- wEventFlags and the per-map scene ids off the save, with the seed above as -- the fallback for a file that has never had it. Both are back before the -- VM is built and long before the first setMap below. self:loadPlayerData(self.game and self.game.save) -- Font.load expects the Gen 1 Data shape: data.font = font.lua table. The -- `font` registry keeps its Gen 1 target under Gen 2, so data.font is -- already the merged table by the time we get here -- a second disk read -- would put the stock glyphs back over a font mod's. local font = self:dataTable("font", "data/generated/font.lua") if font then local okFont, fontErr = pcall(Font.load, { font = font }) if not okFont then self.status = Strings("Font load failed:\n%s", tostring(fontErr)) return false end end self.vm = Vm.new(self.scripts, self.text, self.events, { eventTables = self.eventTables, -- The `commands` registry as merged into data.commands, which is the -- shared Gen 1 target (it is absent from Schemas.GEN2 on purpose). The -- VM resolves a mod-authored `modcommand` row's verb out of this table -- (src/script/gen2/Vm.lua:runModCommand); a cart row never reaches it. -- Read straight off game.data rather than through dataTable: there is no -- data/generated/commands.lua to fall back to, and nil here is the honest -- answer for a mod-free boot -- the only check the dispatch arm pays. commands = self.game and self.game.data and self.game.data.commands, -- Vm:resume hands the one-command lookahead through as the third argument -- (Vm:textStays): the next row is `yesorno`, so this text ended in `done` -- and the cart never took the box down before YesNoBox went up over it -- (home/text.asm:484 DoneText returns with no PromptButton, unlike -- PromptText). Dropping the argument here left World:showText's `stay` -- branch and World:askYesNo's held arm unreachable, which cost a button -- press the cart never asks for and re-printed the question under the -- prompt. -- `hold` is the same story one argument along: the cart `pause` a held box -- stands through (FindItemInBallScript's `pause 60`). Dropping it made the -- box hand back the instant it finished typing. showText = function(body, onDone, stay, hold) self:showText(body, onDone, stay, hold) end, facePlayer = function() if self.talkNpc and self.player then self.talkNpc:facePlayer(self.player) end end, onFlagsChanged = function() -- A flag set MID-SCRIPT must not change which objects are on the map. -- The cart only re-reads the object list when the map loads -- (RefreshMapSprites, after a warp or a scene change), so a script that -- sets an object's event flag halfway through -- MeetMomScript sets -- EVENT_PLAYERS_HOUSE_MOM_1 and clears MOM_2 while Mom is still -- standing next to the player -- would otherwise swap the standing Mom -- for the sitting one on the spot, which reads as her teleporting into -- her chair mid-sentence. Deferred to the end of the script instead. if self:scriptRunning() then self.peopleDirty = true return end self:rebuildPeople({ seamless = true }) end, setScene = function(scene) if self.map then self.mapScenes[self.map.id] = scene or 0 end end, getScene = function() return self.map and (self.mapScenes[self.map.id] or 0) or 0 end, setMapScene = function(group, mapNum, scene) local mapId = self:mapIdByGroupMap(group, mapNum) if mapId then self.mapScenes[mapId] = scene or 0 end end, turnObject = function(objectId, facing) self:turnObject(objectId, facing) end, applyMovement = function(objectId, bytes, onDone) self:beginMovement(objectId, bytes, onDone) end, follow = function(leader, follower) self:startFollow(leader, follower) end, stopFollow = function() self:stopFollow() end, -- StartAutoInput / StopAutoInput (home/joypad.asm). The ring itself lives -- on the game so it outlives a map load, and it is stepped once per fixed -- step ahead of Input:step; see src/core/gen2/AutoInput.lua. autoInput = function(bank, address) local ring = self.game and self.game.autoInput if ring then ring:startPointer(bank, address, self.game.input) end end, autoInputStream = function(name) local ring = self.game and self.game.autoInput if ring then ring:start(name, self.game.input) end end, stopAutoInput = function() local ring = self.game and self.game.autoInput if ring then ring:stop(self.game.input) end end, yesorno = function(onChoose) self:askYesNo(onChoose) end, disappear = function(objectId) self:disappearObject(objectId) end, showPic = function(speciesIndex) self:showPokePic(speciesIndex) end, hidePic = function() self.pokePic = nil end, -- WaitButton (home/text.asm), for the `waitbutton` that sits under an open -- `pokepic` window and so has no text box to have taken the press for it. -- See World:waitForButton. waitButton = function(done) self:waitForButton(done) end, getMonName = function(speciesIndex) local id, def = speciesByIndex( self.game and self.game.data and self.game.data.pokemon, speciesIndex) return (def and def.name) or id or "?" end, getItemName = function(itemIndex) local id, def = itemByIndex( self.game and self.game.data and self.game.data.items, itemIndex) if def and def.name then return def.name end return id or ("ITEM" .. tostring(itemIndex)) end, -- CheckItemPocket (engine/items/items.asm) on wCurItem: the pocket id -- behind ItemPocketNames, which GetPocketName copies into wStringBuffer3 -- for _PutItemInPocketText and _PocketIsFullText. Same lookup -- World:specialSound already makes for the TM/HM jingle. getItemPocket = function(itemIndex) local _, def = itemByIndex( self.game and self.game.data and self.game.data.items, itemIndex) return def and def.pocket or nil end, -- GetTrainerName, not Battle_GetTrainerName: the operand pair IS the -- class and member, so nothing here reads wOtherTrainer*. CAL takes its -- own arm before the table is touched (src/world/gen2/TrainerHouse.lua). getTrainerName = function(group, index) return TrainerHouse.name(self.game and self.game.data and self.game.data.trainers, self.game and self.game.save, group, index) end, -- Mirror wStringBuffer2 onto the game so the shared {STRBUF} token can -- fill any page the VM itself did not build. setStringBuffer = function(value) if self.game then self.game.stringBuffer = value end end, givePoke = function(speciesIndex, level, item) local data = self.game and self.game.data local save = self.game and self.game.save if not (data and save) then return end save.party = save.party or {} local mon = givePokeMon(data, speciesIndex, level, item) if mon then -- GivePoke -> TryAddMonToParty -> AddPartyMon (move_mon.asm:44-56, :143-149). Mon.stampOT(save, mon) Party.add(save.party, mon) -- GivePoke ends in SetSeenAndCaughtMon, which is why the STARTER is -- already ticked off in the #DEX before the first battle. save.pokedex = save.pokedex or { seen = {}, caught = {} } save.pokedex.seen[mon.species] = true save.pokedex.caught[mon.species] = true -- AddPartyMon's `.registerunowndex` runs on the same path, so a gifted -- Unown lands in the form list too (nothing in Gold gives one, but the -- cart's check is on the species, not on where it came from). Unown.registerCatch(save, mon) end end, giveItem = function(itemIndex, qty) local data = self.game and self.game.data local save = self.game and self.game.save if not save then return false end save.inventory = save.inventory or {} local id = itemByIndex(data and data.items, itemIndex) if not id then id = "ITEM_" .. tostring(itemIndex) end return Bag.add(save, id, qty or 1, data) end, addCell = function(phone) local save = self.game and self.game.save if not save then return end save.phoneContacts = save.phoneContacts or {} save.phoneContacts[phone] = true end, delCell = function(phone) local save = self.game and self.game.save if not save or not save.phoneContacts then return end save.phoneContacts[phone] = nil end, hasCell = function(phone) local save = self.game and self.game.save return save and save.phoneContacts and save.phoneContacts[phone] == true end, cry = function(speciesIndex) self:playCry(speciesIndex) end, playSound = function(sfxId) self:playSfx(sfxId) end, playMusic = function(musicId) self:playMusicId(musicId) end, specialSound = function(itemIndex) self:specialSound(itemIndex) end, -- WaitSFX is `call CheckSFX / jr c, WaitSFX` on wCurSFX, so it waits on -- WHATEVER sound is on the channels, not only on the ones a script -- started. Phone_StartRinging (engine/phone/phone.asm:564) is the caller -- that needs the wider reading: the A-press beep that dismissed the box -- before the call is the sound most likely to still be running, and -- SFX_CALL ($6a) is quiet enough that the priority gate drops the ring if -- it fires over one. Sound.sfxBusy() is that wCurSFX; World.lastSfx only -- covers the sounds World itself started. waitSfx = function() if require("src.core.Sound").sfxBusy() then return false end local src = self.lastSfx if not src then return true end local ok, playing = pcall(src.isPlaying, src) return not (ok and playing) end, readVar = function(varId) return self:readVar(varId) end, -- `special` ids resolve through the extracted SpecialsPointers order. specialOrder = self.constants and self.constants.specialOrder, lookupTrainer = function(class, member) return self:trainerParty(class, member) end, startBattle = function(trainer, wild, onDone) self:startScriptedBattle(trainer, wild, onDone) end, catchTutorial = function(wild, battleType, onDone) self:startCatchTutorial(wild, battleType, onDone) end, -- `setup` is true for the ops that run a map SETUP script (`reloadmap`, -- `reloadmapafterbattle`), false for `refreshmap`, which is only -- LoadOverworldTilemapAndAttrmapPals / ApplyTilemap / UpdateSprites and -- runs no setup script at all (engine/overworld/scripting.asm:2044). Only -- the setup arm carries the music row, and it runs BEFORE the deferral -- below so a reload mid-scene still consumes wDontPlayMapMusicOnReload. reloadMap = function(setup) if setup then self:forceMapMusic() -- Script_reloadmap re-enters through MAPSTATUS_ENTER (engine/overworld/ -- scripting.asm:1108-1116), so a wild battle re-arms EnterMap's cooldown. self.wildCooldown = 5 end -- MapSetupScript_ReloadMap (data/maps/setup_scripts.asm:124) has NO -- LoadMapObjects: `reloadmap` / `reloadmapafterbattle` reload blocks, -- graphics, palettes and music and leave the object structs standing. -- So a flag a script set BEFORE the battle must not cull anybody -- mid-scene: AzaleaTownRivalBattleScript sets EVENT_RIVAL_AZALEA_TOWN at -- maps/AzaleaTown.asm:57, well before `startbattle`, and only -- `disappear`s the rival after the after-battle text and his exit walk. -- Same deferral onFlagsChanged uses above, for the same reason. if self:scriptRunning() then self.peopleDirty = true return end self:rebuildPeople({ seamless = true }) end, -- `warp NONE, 0, 0`, Script_warp's own group-0 arm. A different thing -- from `reloadmap` above: MapSetupScript_BadWarp carries HandleNewMap and -- LoadMapObjects, MapSetupScript_ReloadMap carries neither, and it is -- precisely the callbacks in HandleNewMap that the bedroom PC's warp is -- there to re-run. badWarp = function() self:reloadMapBadWarp("bad_warp") end, encounterMusic = function(class) self:playTrainerEncounterMusic(class) end, showEmote = function(emote, object, frames) self:showEmote(emote, object, frames) end, trainerApproach = function(onDone) self:trainerApproach(onDone) end, faceObject = function(a, b) -- faceobject PLAYER, LAST_TALKED: PLAYER is 0, LAST_TALKED is -1/$fe, -- and only the player-turns-to-trainer case is ever scripted here. if (a or 0) == 0 and self.player and self.trainerNpc then local dx = self.trainerNpc.cellX - self.player.cellX local dy = self.trainerNpc.cellY - self.player.cellY if math.abs(dx) > math.abs(dy) then self.player.facing = dx > 0 and "right" or "left" elseif dy ~= 0 then self.player.facing = dy > 0 and "down" or "up" end end local _ = b end, openPc = function() self:openPc() end, -- engine/menus/menu_2.asm's three balance boxes. Each is a `special` that -- draws a box and RETURNS, and every one of the 23 calls in the game is -- followed straight away by `loadmenu` -- the Game Corner prize counters -- and the coin vendor, the vending machines -- so the box belongs to the -- static menu that answers. Remembered here and handed to that screen -- rather than drawn on the spot, because the menu is what owns the frame. showCoins = function() self.scriptBalance = "coins" end, showMoney = function(kind) self.scriptBalance = kind or "money" end, openMart = function(martType, martId, onDone) self:openMart(martType, martId, onDone) end, openMenu = function(header, style, onChoose) self:openScriptMenu(header, style, onChoose) end, elevator = function(floors, onDone) self:openElevator(floors, onDone) end, npcTrade = function(id, onDone) self:openNpcTrade(id, onDone) end, -- Script_wildoff / Script_wildon (engine/overworld/scripting.asm), which -- set and clear STATUSFLAGS_NO_WILD_ENCOUNTERS_F. Wired from this side -- ahead of the VM opcodes so the gate is honoured the moment they land. setWildEncounters = function(on) self.noWildEncounters = not on end, healParty = function() self:healParty() end, healAnim = function(animType, onDone) self:startHealMachineAnim(animType, onDone) end, nameRival = function(onDone) self:nameRival(onDone) end, warpToSpawn = function() self:warpToSpawn() end, -- ---- scene, clock, cartridge ------------------------------------------- getMapScene = function(group, mapNum) return self:mapSceneOf(group, mapNum) end, getTimeOfDay = function() return self:timeOfDayId() end, gsVersion = function() return self:gsVersion() end, -- ---- ENGINE_* flags ---------------------------------------------------- -- A DIFFERENT namespace from `setevent`'s wEventFlags: badges, the Pokegear -- cards, ENGINE_POKEDEX and the Bug Contest timer live here, and none of -- them decides whether an object is on the map, which is why neither hook -- touches onFlagsChanged. getEngineFlag = function(flag) return self:engineFlag(flag) end, setEngineFlag = function(flag, value) self:setEngineFlag(flag, value) end, -- ---- vars -------------------------------------------------------------- writeVar = function(varId, value) self:writeVar(varId, value) end, -- `callasm` / `memcallasm`: raw GB code at bank:addr. The importer does -- not resolve the pair against pokegold-symbols/pokegold.sym, so `label` is -- nil and the ADDRESS is what dispatches -- which is why -- src/script/gen2/CallAsm.lua keys on it. A nil back leaves wScriptVar -- alone, the answer for every routine whose asm does not write it. callAsm = function(label, bank, addr) return self:callAsm(label, bank, addr) end, -- ---- map objects ------------------------------------------------------- appear = function(objectId) self:appearObject(objectId) end, moveObject = function(objectId, cx, cy) self:moveObject(objectId, cx, cy) end, variableSprite = function(slot, sprite) self:setVariableSprite(slot, sprite) end, -- DescribeDecoration's read of the wDeco* byte its arm names, plus -- GetDecorationName_c_de's wStringBuffer3 for the three arms that print it. decorationSlot = function(descName) local desc = Decorations.DESC_SLOTS[descName or ""] if not desc then return nil end local state = Decorations.state(self.game and self.game.save) local decoId = state[desc.slot] or 0 if decoId == 0 then return 0, nil end return decoId, desc.named and Decorations.name(decoId) or nil end, -- LoadEmote is a VRAM preload; World:showEmote picks the sheet by index at -- draw time, so there is nothing to warm up. The VM keeps its own -- `loadedEmote` for the movement byte that carries no id. -- ---- map blocks and warps ---------------------------------------------- changeBlock = function(bx, by, blockId) self:changeBlock(bx, by, blockId) end, changeMapBlocks = function(bank, address) return self:changeMapBlocks(bank, address) end, earthquake = function(displacement, frames) self:earthquake(displacement, frames) end, warpTo = function(group, mapNum, cx, cy, facing) self:warpTo(group, mapNum, cx, cy, facing) end, warpCheck = function() self:armWarpCheck() end, warpSound = function() self:warpSound() end, writeCmdQueue = function() return self:writeCmdQueue() end, delCmdQueue = function(kind) return self:delCmdQueue(kind) end, newLoadMap = function(method) self:newLoadMap(method) end, setWarpMod = function(warpId, group, mapNum) self:setWarpMod(warpId, group, mapNum) end, setBlackoutMap = function(group, mapNum) self:setBlackoutMap(group, mapNum) end, -- ---- encounters -------------------------------------------------------- setSwarm = function(group, mapNum) self:setSwarm(group, mapNum) 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. readMem = function(addr) return self:scriptReadMem(addr) end, -- ---- music ------------------------------------------------------------- playMapMusic = function() self:playMapMusic() end, fadeOutMusic = function(musicId, fade) self:fadeOutMusic(musicId, fade) end, dontRestartMapMusic = function() self.dontRestartMusic = true end, -- ---- bag, money and coins ---------------------------------------------- hasItem = function(itemIndex) return self:hasItem(itemIndex) end, takeItem = function(itemIndex, qty) return self:takeItem(itemIndex, qty) end, getMoney = function(account) return self:money(account) end, setMoney = function(account, value) self:setMoney(account, value) end, getCoins = function() return self:coins() end, setCoins = function(value) self:setCoins(value) end, -- ---- party ------------------------------------------------------------- hasPoke = function(speciesIndex) return self:hasPoke(speciesIndex) end, giveEgg = function(speciesIndex, level) return self:giveEgg(speciesIndex, level) end, -- `givepokemail` and `checkpokemail` are script COMMANDS ($ea, $eb), not -- SpecialsPointers rows, so they belong on the VM's own hook table beside -- giveegg the way Vm.new reads them. They are also listed in -- `specials` below, which is where a handler that wanted the same seam -- would find them; the command arm cannot see that sub-table. givePokeMail = function(mail) return self:givePokeMail(mail) end, checkPokeMail = function(mail, onDone) self:checkPokeMail(mail, onDone) end, getLandmarkName = function() return self:landmarkName() end, -- ---- field events ------------------------------------------------------ fruitTreeItem = function(tree) return self:fruitTreeItem(tree) end, fruitTreeReset = function() return self:fruitTreeReset() end, fruitTreePicked = function(tree) return self:fruitTreePicked(tree) end, fruitTreePick = function(tree) self:fruitTreePick(tree) end, -- ---- phone ------------------------------------------------------------- addPhoneNumber = function(contact) return self:addPhoneNumber(contact) end, setSpecialCall = function(id) self:setSpecialCall(id) end, getSpecialCall = function() return self:specialCall() end, -- ---- end of game ------------------------------------------------------- -- Script_halloffame and Script_credits both end on ReturnFromCredits -- (Script_endall + MAPSTATUS_DONE), so the VM returns out of the script the -- moment either hook is present and neither callback resumes anything the -- script still needs. They are here rather than in `specials` because both -- really are script COMMANDS ($9f, $a0), not SpecialsPointers rows. hallOfFame = function(onDone) self:hallOfFame(onDone) end, credits = function(onDone) self:credits(onDone) end, -- ---- the specials ------------------------------------------------------ -- Everything under src/script/gen2/Specials.lua that has to touch the -- world reaches it through this ONE sub-table rather than through a -- hundred more `xFn` fields on the VM: a special is an independent routine -- and the table is its whole surface, so a handler stays a description of -- the cart routine and the World keeps its own seams. specials = self:specialHooks(), }) -- The readmem / writemem bytes a script owns outright (the Goldenrod -- underground switches, wMooMooBerries) ride the save under `scriptMem`, so -- hand them back before any script runs. Without this the switch room and -- the barn silently reset every time the game is reloaded. local savedMem = self.game and self.game.save and self.game.save.scriptMem if savedMem then self.vm:restoreMem(savedMem) end -- Where to start: a restored save's own position, else SPAWN_HOME. local startMap, startX, startY, startFacing = START_MAP, START_X, START_Y, START_FACING local spawn = self.landmarks and self.landmarks.spawns and self.landmarks.spawns[SPAWN_HOME] if spawn and spawn.map and maps[spawn.map] then startMap, startX, startY = spawn.map, spawn.x, spawn.y end local saved = self.game and self.game.save and self.game.save.position -- Which map setup script this load is (engine/menus/intro_menu.asm): a file -- with a recorded position is CONTINUE, a New Game is the SPAWN_HOME warp. -- setMap reads it back to decide whether HandleNewMap's temporary-flag reset -- runs. local isContinue = false if saved and saved.map and maps[saved.map] then startMap, startX, startY = saved.map, saved.x, saved.y startFacing = saved.facing or startFacing isContinue = true end -- `farcall JumpRoamMons`, three lines above that same read: EVERY load of a -- save scatters the three beasts to random roam maps before the map comes -- back, which is what makes re-finding one the price of reloading after a -- failed catch. The map the jump avoids is the one the save was written on, -- so this runs while startMap is still the SAVED position and not the -- post-credits spawn below. A file with no InitRoamMons behind it has no -- save.roamers and Roamers.jumpAll leaves it that way. self:roamMonsOnContinue(startMap) -- Continue (engine/menus/intro_menu.asm): `ld a, [wSpawnAfterChampion]` is -- read BEFORE the saved position is honoured, and a pending value replaces -- it outright -- .SpawnAfterE4 / SpawnAfterRed write wDefaultSpawnpoint and -- enter through PostCreditsSpawn's MAPSETUP_WARP instead of -- MAPSETUP_CONTINUE. So the champion whose induction saved them standing -- in the Hall of Fame continues in New Bark Town, not in a room whose only -- exit is sealed. local post = self:consumePostGameSpawn() if post then startMap, startX, startY, startFacing = post.map, post.x, post.y, "down" isContinue = false end if not maps[startMap] then self.status = Strings("%s is missing.\nRe-import the Gold ROM.", tostring(startMap)) return false end local ok, err = pcall(function() self:setMap(startMap, startX, startY, startFacing, { continue = isContinue }) end) if not ok then self.status = Strings("Failed to boot %s:\n%s", tostring(startMap), tostring(err)) return false end return self.map ~= nil end -- Just the VM, not everything World:busy covers: a deferred object rebuild -- has to wait for the SCRIPT, not for the text box that is showing its line. function World:scriptRunning() return (self.vm and self.vm:running()) and true or false end function World:busy() return (self.vm and self.vm:running()) -- The map setup script is a blocking call inside the cart's overworld loop -- (RunMapSetupScript, with the fades and the load inside it), so nothing -- else may run while it does -- least of all a step from a direction the -- player is still holding from before the warp. or self.mapSetup ~= nil or self.textbox ~= nil or self.moveState ~= nil or self.choicebox ~= nil -- The rod cast and the tree shake are frame counters with no text box up -- for part of their run; on the cart they are script commands, so the -- world is frozen for them too and the player cannot walk out from under -- the animation. or self.fishing ~= nil or self.headbutt ~= nil -- Same again for a field move's tail: the surf step, the STRENGTH pause -- and the waterfall climb are all applymovement / pause commands inside a -- queued script, so nothing else may run under them. or self.fieldMove ~= nil end -- CheckMenuOW (engine/overworld/events.asm:802) is the tail of OWPlayerInput, -- and OWPlayerInput is only reached from PlayerEvents, which returns straight -- away while wScriptRunning is non-zero (events.asm:238-243). Two more gates -- sit above it even with no script up: PlayerMovement answering -- PLAYERMOVEMENT_CONTINUE, i.e. the player is mid-step (events.asm:474-477), -- and CheckStandingOnIce carrying (events.asm:479-480). START and SELECT are -- read NOWHERE else in the overworld, so a press that arrives while any of -- those hold is not queued or deferred, it is never read at all. function World:acceptsMenuInput() if self.battleActive or self:busy() then return false end if self.player and self.player.moving then return false end -- The same latch pair World:step's slide uses: a latched direction on an ice -- tile is CheckStandingOnIce's carry. if self.turningDirection and Permissions.isIce(self:playerCollision()) then return false end return true end function World:mapIdByGroupMap(group, mapNum) if not self.maps then return nil end for id, def in pairs(self.maps) do if type(def) == "table" and def.group == group and def.map == mapNum then return id end end return nil end function World:scene() if not self.map then return 0 end return self.mapScenes[self.map.id] or 0 end -- ---- the script VM's world hooks ------------------------------------------- -- -- Everything from here to World:specialHooks is one script command or one -- special reaching into the world. They are gathered rather than scattered -- because they share one property: each is the WORLD half of a routine whose -- other half is transcribed in src/script/gen2/Vm.lua or Specials.lua, and the -- VM guards every call with `if self.xFn then`, so the interpreter stays -- correct when a hook is missing and only stops being able to SHOW the result. -- GetMapSceneID (engine/overworld/scripting.asm): a map with no `scene_var` -- row at all leaves de = 0 and Script_checkmapscene answers $ff, which is what -- nil means here. A map that HAS scene scripts but has never been given a -- scene is scene 0. function World:mapSceneOf(group, mapNum) local mapId = self:mapIdByGroupMap(group, mapNum) if not mapId then return nil end local def = self.maps and self.maps[mapId] if not (def and def.sceneScripts) then return nil end return self.mapScenes[mapId] or 0 end -- wTimeOfDay (constants/ram_constants.asm): MORN_F 0, DAY_F 1, NITE_F 2, -- DARKNESS_F 3. Palettes.daytimeFor has already resolved the clock and the -- map's own PALETTE_* override into one of four names, so this is a rename -- rather than a second clock. local TIME_OF_DAY_ID = { MORN = 0, DAY = 1, NITE = 2, DARK = 3 } function World:timeOfDayId() return TIME_OF_DAY_ID[self.daytime or "DAY"] or 1 end -- GetWeekday -> wCurDay, which the RTC counts SUNDAY 0 .. SATURDAY 6 -- the -- same numbering os.date("%w") answers, so no remap. `clockDay` overrides the -- host clock the way `clockHour` overrides the hour, so a driver can stand on -- Route 29 on a Tuesday and see Tuscany. function World:weekday() if self.clockDay then return math.floor(self.clockDay) % 7 end -- Through the base InitDayOfWeek stored, not off the host clock raw: Mom's -- wheel is what decides which day the game is on (src/core/gen2/Clock.lua). return Clock.weekday(self.game and self.game.save) end -- hHours, which VAR_HOUR reads straight off: RTC hour 0..23. `clockHour` -- overrides the host clock the same way it does for the daytime palette. function World:hour() if self.clockHour then return math.floor(self.clockHour) % 24 end -- CalcNSecsHoursDaysSince reads the RTC through wStartHour / wStartMinute, -- the base InitClock wrote when the player answered Oak; a save from before -- that screen existed has no base and reads the host clock straight through. return Clock.hour(self.game and self.game.save) end -- hMinutes, the other half of the same read. The Pokegear clock card and the -- DST confirmations are what want it. function World:minute() return Clock.minute(self.game and self.game.save) end -- engine/overworld/variables.asm .VarActionTable, walked in order. readVar -- and writevar/loadvar share the id space (GetVarAction resolves both), but -- only the handful of ADDR_DE rows (VAR_BATTLETYPE, VAR_MOVEMENT) are ever -- written back through writeVar/self.scriptVars; the rest are RETVAR_EXECUTE -- or RETVAR_STRBUF2 rows that just read state the engine already owns. function World:readVar(varId) if varId == VAR_FACING and self.player then return FACING_ID[self.player.facing] or 0 end if varId == VAR_WEEKDAY then return self:weekday() end if varId == VAR_BATTLETYPE then return self.scriptVars[VAR_BATTLETYPE] or 0 end local save = self.game and self.game.save if varId == VAR_PARTYCOUNT then return save and #(save.party or {}) or 0 end if varId == VAR_BATTLERESULT then -- wBattleResult masked with ~BATTLERESULT_BITMASK (the box-full flag); -- the port never sets that bit, so the stored value already matches. return self.lastBattleResult or 0 end if varId == VAR_TIMEOFDAY then return self:timeOfDayId() end if varId == VAR_DEXCAUGHT then return countFlags(save and save.pokedex and save.pokedex.caught) end if varId == VAR_DEXSEEN then return countFlags(save and save.pokedex and save.pokedex.seen) end if varId == VAR_BADGES then -- wBadges is TWO bytes (Johto then Kanto); CountSetBits walks both. local player = save and save.player return countFlags(player and player.badges) + countFlags(player and player.kantoBadges) end if varId == VAR_MOVEMENT then return PLAYER_STATE_ID[self.playerState] or 0 end if varId == VAR_HOUR then return self:hour() end if varId == VAR_MAPGROUP then return (self.map and self.map.def and self.map.def.group) or 0 end if varId == VAR_MAPNUMBER then return (self.map and self.map.def and self.map.def.map) or 0 end if varId == VAR_UNOWNCOUNT then -- CountUnown walks wUnownDex, a list of the distinct Unown FORMS caught in -- catching order. save.pokedex still only knows the SPECIES; the form list -- is its own record (save.unownDex, src/core/gen2/Unown.lua), written by -- the same two events the cart writes it on. return Unown.count(save) end if varId == VAR_ENVIRONMENT then return (self.map and self.map.def and self.map.def.environmentId) or 0 end if varId == VAR_BOXSPACE then if not save then return 0 end return Boxes.MONS_PER_BOX - Boxes.count(save, save.currentBox) end if varId == VAR_CONTESTMINUTES then if not save then return 0 end local minutes = BugContest.timeLeft(save) return minutes end if varId == VAR_XCOORD then return (self.player and self.player.cellX) or 0 end if varId == VAR_YCOORD then return (self.player and self.player.cellY) or 0 end if varId == VAR_SPECIALPHONECALL then return self:specialCall() end return 0 end -- Script_checkver: 0 for Gold, 1 for Silver (constants/misc_constants.asm -- GS_VERSION). Only the Goldenrod prize counters and a handful of gift mons -- read it, and the port has no Silver cache yet, so an unset version is Gold. function World:gsVersion() local save = self.game and self.game.save local version = (save and save.version) or "gold" return version == "silver" and 1 or 0 end -- ENGINE_* flags (data/events/engine_flags.asm), the namespace `setflag` / -- `clearflag` / `checkflag` write. Kept on the save under its own key rather -- than merged into `events`, because the two tables index different arrays on -- the cart (wEngineBuffer / wBadges / wPokegearFlags vs wEventFlags) and a -- collision would have BADGE_ZEPHYR hide an NPC. -- -- It deliberately does NOT rebuild the map's people: no engine flag names an -- object's MAPOBJECT_EVENT_FLAG. function World:engineFlags() local save = self.game and self.game.save if not save then return self._engineFlags or {} end save.engineFlags = save.engineFlags or {} return save.engineFlags end function World:engineFlag(flag) if flag == nil then return false end local save = self.game and self.game.save -- ENGINE_BUG_CONTEST_TIMER is not a bit of its own here: it IS -- save.bugContest.active, because that is what CheckTimeEvents polls and what -- RandomEncounter branches on. Keeping a second copy in the flag table is -- how the two would come apart -- the officer's `setflag` and the results -- script's `clearflag` are the only writers, and both go through the pair -- below. if flag == BugContest.ENGINE_BUG_CONTEST_TIMER and save then return BugContest.isActive(save) end -- Badges live in save.player.badges, not in the flag table: on the cart the -- ENGINE_*BADGE ids ARE the bits of wJohtoBadges/wKantoBadges, so there is -- only one store and everything that asks (field moves, VAR_BADGES, the -- trainer card) has to see the same answer. See FieldMoves.BADGE_FLAG. local badge = FieldMoves.BADGE_FLAG[flag] if badge and save then local player = save.player local owned = player and player[badge.store] return type(owned) == "table" and owned[badge.name] == true 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, -- DayCareStep and the deposit/withdraw routines write, so `checkflag` reads -- the deposit state directly. Route34EggCheckCallback branches on all three -- (maps/Route34.asm:21-49) to put the gramps in the yard and to un-hide the -- two day-care mon objects; a second copy in save.engineFlags is exactly how -- the yard stayed empty forever. if save then if flag == ENGINE_DAY_CARE_MAN_HAS_EGG then return Breeding.dayCare(save).hasEgg == true elseif flag == ENGINE_DAY_CARE_MAN_HAS_MON then return (Breeding.side(save, "man") or {}).mon ~= nil elseif flag == ENGINE_DAY_CARE_LADY_HAS_MON then return (Breeding.side(save, "lady") or {}).mon ~= nil end end return self:engineFlags()[flag] == true end function World:setEngineFlag(flag, value) if flag == nil then return end local save = self.game and self.game.save if flag == BugContest.ENGINE_BUG_CONTEST_TIMER and save then -- Route35NationalParkGate_OkayToProceed sets the flag BEFORE `special -- GiveParkBalls`, so starting here and starting again there is the cart's -- own order and the second start is what puts the balls on the counter. -- BugContestResultsScript's clearflag is the stop, and it deliberately -- leaves the caught mon alone: CheckPartyFullAfterContest runs after it. if value then BugContest.start(save) else BugContest.stop(save) end return end local badge = FieldMoves.BADGE_FLAG[flag] if badge and save then save.player = save.player or {} save.player[badge.store] = save.player[badge.store] or {} save.player[badge.store][badge.name] = value and true or nil 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 -- DayCareManOutside already did `res DAYCAREMAN_HAS_EGG_F, [hl]` -- (engine/events/daycare.asm:393), which is Breeding.collectEgg here. The -- two HAS_MON bits belong to the deposit/withdraw routines, so a script -- write to them would be a second store: swallow it. if save then if flag == ENGINE_DAY_CARE_MAN_HAS_EGG then Breeding.dayCare(save).hasEgg = value and true or false return elseif flag == ENGINE_DAY_CARE_MAN_HAS_MON or flag == ENGINE_DAY_CARE_LADY_HAS_MON then return end end local flags = self:engineFlags() flags[flag] = value and true or nil end -- Script_writevar / Script_loadvar. VAR_BATTLETYPE is the only slot anything -- reads BACK out of scriptVars today, and startScriptedBattle is where it is -- consumed. -- -- VAR_MOVEMENT is the exception, and it is not a stored value at all: its row -- in .VarActionTable is the ADDRESS of wPlayerState, so `loadvar VAR_MOVEMENT, -- PLAYER_BIKE` changes the player's state outright. That is the whole of -- Script_GetOnBike -- the `special UpdatePlayerSprite` after it only reloads -- the sheet applyPlayerState has already picked. function World:writeVar(varId, value) if varId == nil then return end self.scriptVars[varId] = value or 0 if varId == VAR_MOVEMENT then local state = PLAYER_STATE_BY_ID[value or 0] if state then self:applyPlayerState(state) end end end function World:battleType() return self.scriptVars[VAR_BATTLETYPE] or 0 end -- Script_callasm / Script_memcallasm: a bank:address into raw GB code. The -- pair is resolved against pokegold-symbols/pokegold.sym in -- src/script/gen2/CallAsm.lua, which is where the routines themselves are -- ported; a site that is not in its table answers nil, and so does a routine -- whose asm writes no wScriptVar. Either way the VM leaves wScriptVar alone -- rather than picking a branch at random for the `callasm` / `iffalse` pairs. -- -- Nothing in the cache dispatches through here. The thirty-eight rows that -- used to carry one of the four opcodes all sat inside keys the extractor had -- made out of three-byte `hiddenitem` bg_event operands, so their addresses -- were noise, and a cache built since that decode landed has none at all. The -- table is reached through CallAsm.run from the hand-ported engine flows -- instead -- countStep's hatch and poison arms, and whiteOut. function World:callAsm(label, bank, addr) return CallAsm.dispatch(self, label, bank, addr) end -- Script_appear: the mirror of World:disappearObject. Both halves have to -- come off, or a disappear/appear pair is one-way: the event flag AND the -- synthetic hide the flagless path wrote under the same key. -- -- The rebuild is immediate rather than deferred through peopleDirty, because -- ApplyEventActionAppearDisappear respawns the object struct inside the -- command -- unlike a plain `setevent`, which the cart only reads back on the -- next map load. function World:appearObject(objectId) local index = (objectId or 0) - 1 local def = self.map and self.map.def local obj = def and def.objects and def.objects[index] if not obj then return end if obj.eventFlag and obj.eventFlag ~= 0xFFFF then self.events:set(obj.eventFlag, false) end -- UnmaskObject (home/map.asm:1548) clears exactly ONE byte of wObjectMasks, -- this object's, and NOTHING re-reads the event flag until the next -- LoadObjectMasks at map load. Objects sharing one MAPOBJECT_EVENT_FLAG are -- ordinary -- the three animated Burned Tower beasts all carry -- EVENT_BURNED_TOWER_B1F_BEASTS_1 (maps/BurnedTowerB1F.asm:152) and -- ReleaseTheBeasts `appear`s them one at a time (:27, :33, :39) -- so the -- flag alone must not put the other two on the map with this one. self:setObjectMask(obj, index, false) -- Script_appear RESPAWNS the object struct out of the MAP object -- (UnmaskCopyMapObjectStruct, home/map_objects.asm:309 -> CopyObjectStruct -> -- CopyMapObjectToObjectStruct, engine/overworld/player_object.asm:207-215, -- which re-seeds the struct's X/Y from MAPOBJECT_X_COORD/MAPOBJECT_Y_COORD), -- so an `appear` takes the cell a preceding `moveobject` wrote. The pooled -- NPC is the OLD struct: keeping it is what left Kurt standing at the well -- entrance after `moveobject SLOWPOKEWELLB1F_KURT, 11, 6` and sent his -- victory walk off from the wrong cell. Dropping it is the literal port of -- the respawn -- pooledNpc rebuilds from the def on the next pass. if self.npcPool then self.npcPool[string.format("%s_obj_%d", self.map.id, obj.index or 0)] = nil end self:rebuildPeople({ seamless = true }) end -- Script_moveobject: MAPOBJECT_X_COORD / MAPOBJECT_Y_COORD, in plain map -- cells. It nearly always names an object that is still HIDDEN (the pairing is -- `moveobject` then `appear`), so the def is written first and the live NPC -- second: a rebuild that has not happened yet must still find the new cell. function World:moveObject(objectId, cellX, cellY) local index = (objectId or 0) - 1 local def = self.map and self.map.def local obj = def and def.objects and def.objects[index] if not (obj and cellX and cellY) then return end local mapId = self.map and self.map.id local key = obj.index or index if mapId then self.objectSpawns = self.objectSpawns or {} self.objectSpawns[mapId] = self.objectSpawns[mapId] or {} if not self.objectSpawns[mapId][key] then self.objectSpawns[mapId][key] = { obj.x, obj.y } end end obj.x, obj.y = cellX, cellY local npc = self:objectEntity(objectId) if npc and npc ~= self.player then npc.cellX, npc.cellY = cellX, cellY npc.px, npc.py = cellX * 16, cellY * 16 npc.moving = false npc.progress = 0 npc.targetX, npc.targetY = nil, nil -- The anim path is anchored on where the object was placed, so a teleported -- NPC that walks a radius has to take its home with it. npc.homeX, npc.homeY = cellX, cellY -- The `appear` beside it re-runs StepFunction_Reset, which re-reads the -- object's own tile (engine/overworld/map_objects.asm:498-511, :196-208). npc.inGrass = self:grassAt(cellX, cellY) npc.grassShake = nil end end -- Every POOLED object whose `sprite` is the SPRITE_VARS byte for `slot`, handed -- the sheet the slot now names -- `special LoadUsedSpritesGFX`, which is the -- command that sits beside `variablesprite` at every one of its four call sites -- (maps/Route36.asm:71, FuchsiaGym.asm:36 and :66, CopycatsHouse2F.asm:24). -- -- The pool is keyed `_obj_` and an NPC holds the SpriteRenderer it -- was created with, so a pooled object otherwise keeps whatever sheet the slot -- held when it was first built. On the cart that cannot happen: LoadUsedSpritesGFX -- and the LoadMapObjects every map load runs both re-read wVariableSprites, and a -- connected map's objects are not loaded at all until the seam crossing loads -- them. This port keeps the neighbor strips' objects pooled as ghosts and -- crosses a connection SEAMLESSLY (World:tryConnection -> setMap{ seamless = -- true }, which keeps npcPool), so without this the pair of TWINS on Route 37 -- -- both SPRITE_WEIRD_TREE, maps/Route37.asm:237-238 -- keep the SPRITE_SUDOWOODO -- sheet they were pooled with while the player was still on Route 36, for the -- whole rest of the visit. Walk north out of the Sudowoodo fight and Ann and -- Anne are two Sudowoodo. -- -- The repaint is in place (NPC:setSpriteDef) and not a retire-and-rebuild. Two -- of the four call sites run with the object standing in front of the player -- mid-conversation -- LassAliceScript is `applymovement FUCHSIAGYM_FUCHSIA_GYM_1, -- Movement_NinjaSpin / faceplayer / variablesprite / special LoadUsedSpritesGFX / -- faceplayer` (FuchsiaGym.asm:61-66) -- and the cart touches no part of the -- object struct there. A fresh NPC table would strand World.talkNpc, .trainerNpc, -- .followState and any live moveState on an object no longer on the map, and -- would drop the ninja back to her map-def cell, facing and unfrozen state -- halfway through unmasking. -- -- An emptied slot is the one case that DOES retire: resolveSprite answers nil, -- nothing can be drawn, and World:pooledNpc is the gate that keeps the object -- off the map until the slot is filled again. function World:repaintVariableSpritePool(slot) if not self.npcPool then return end local byte = SPRITE_VARS + slot for key, npc in pairs(self.npcPool) do if npc.def and npc.def.sprite == byte then local name = self:resolveSprite(byte) local spriteDef = type(name) == "table" and name or (name and self.sprites and self.sprites[name]) if spriteDef then if npc:setSpriteDef(spriteDef) then self:applySpritePalette(npc) end else self.npcPool[key] = nil end end end end -- Script_variablesprite: wVariableSprites[slot] = sprite byte. Filling the -- slot is what puts the Sudowoodo, the Copycat, the Olivine rival and the four -- Fuchsia Gym Janines on the map at all -- their objects carry a NUMBER in -- `sprite` ($f0..$fc) and World:pooledNpc finds no sheet for one until here. -- -- REFILLING it is the other half, and it is what Route 36 does: the slot holds -- SPRITE_SUDOWOODO from InitializeEventsScript until the fight, and -- WateredWeirdTreeScript's `variablesprite SPRITE_WEIRD_TREE, SPRITE_TWIN` -- (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. function World:setVariableSprite(slot, spriteIndex) if slot == nil then return end self.variableSprites[slot] = spriteIndex self:repaintVariableSpritePool(slot) self:rebuildPeople({ seamless = true }) end -- The other half of the above: an object whose `sprite` is a SPRITE_VARS byte -- resolves through the slot table and constants.spriteOrder (1-based, because -- sprite_constants.asm's block is `const_def 1`). An unfilled slot answers nil -- and the object simply does not spawn, which is the cart's behaviour too. -- InitializeEventsScript's `variablesprite` list, recovered from scripts.lua. -- -- Only needed for a cache written before the extractor learned to record them; -- `initial_events.sprites` is the direct answer once one is rebuilt. The -- script is identified by its content rather than by a symbol, which the -- runtime does not have: it is the one whose `setevent` ids are exactly the -- seed list this same file already trusts. function World:findInitialSprites() local wanted = {} local count = 0 for _, id in ipairs(self.initialEvents or {}) do wanted[id] = true count = count + 1 end if count == 0 or type(self.scripts) ~= "table" then return {} end for key, list in pairs(self.scripts) do if type(key) == "string" and type(list) == "table" then local hits, sprites = 0, {} for _, cmd in ipairs(list) do if type(cmd) == "table" then if cmd.op == "setevent" then local id = cmd.event or (cmd.args and cmd.args[1]) if id and wanted[id] then hits = hits + 1 end elseif cmd.op == "variablesprite" then local args = cmd.args or {} local slot = cmd.slot or args[1] local sprite = cmd.sprite or args[2] if slot and sprite then sprites[#sprites + 1] = { slot = slot, sprite = sprite } end end end end if hits == count and #sprites > 0 then return sprites end end end return {} end -- GetMonSprite's .BreedMon1 / .BreedMon2 tail (engine/overworld/overworld.asm: -- 279-305): both arms fall into .Mon, which is LoadOverworldMonIcon of the -- deposited species. There is no sprites.lua row to name here -- the species -- is whatever the player left in the day care -- so the def is built in the -- shape RomExtractorGen2:extractMonSprites emits for every other -- POKEMON_SPRITE row and World:pooledNpc takes it directly. -- -- Keyed on species and cached, which is safe because World.npcPool is keyed -- `_obj_` only: a swap in the day care while the pool is warm -- would otherwise leave the old icon standing. rebuildPeople empties the pool -- on map entry, which is exactly when the cart reloads the sprite too -- (LoadMapObjects), so there is nothing further to invalidate. function World:breedmonSpriteDef(species) if not species then return nil end self.breedmonSprites = self.breedmonSprites or {} local hit = self.breedmonSprites[species] if hit ~= nil then return hit or nil end local icons = self.game and self.game.data and self.game.data.gen2Icons local iconId = icons and icons.species and icons.species[species] local entry = iconId and icons.icons and icons.icons[iconId] if not (entry and entry.image) then self.breedmonSprites[species] = false return nil end local def = { id = "SPRITE_DAY_CARE_MON", image = entry.image, frames = 1, walker = false, spriteType = "POKEMON_SPRITE", palette = "PAL_OW_RED", paletteId = 0, species = species, icon = iconId, } self.breedmonSprites[species] = def return def end function World:resolveSprite(sprite) if type(sprite) ~= "number" then return sprite end -- GetMonSprite tests the two day-care bytes ABOVE the SPRITE_VARS range, so -- they must never reach the wVariableSprites arm. .NoBreedmon answers sprite -- 1 for an empty slot; nil is the honest port, because an empty slot leaves -- the object's own event flag (EVENT_DAY_CARE_MON_1/2) set and -- Route34EggCheckCallback only clears it once checkflag says a mon is there. if sprite == SPRITE_DAY_CARE_MON_1 or sprite == SPRITE_DAY_CARE_MON_2 then local save = self.game and self.game.save local slot = save and Breeding.side(save, sprite == SPRITE_DAY_CARE_MON_1 and "man" or "lady") local mon = slot and slot.mon return mon and self:breedmonSpriteDef(mon.species) or nil end if sprite < SPRITE_VARS then return nil end local byte = self.variableSprites[sprite - SPRITE_VARS] if not byte or byte == 0 then return nil end local order = self.constants and self.constants.spriteOrder return order and order[byte] or nil end -- Script_changeblock. The VM has already halved the script's cell coords into -- block coords; replaceBlock is the same buffer edit CUT and WHIRLPOOL make, -- which means the change is undone by the next LoadMapAttributes (World:setMap -- calls restoreBlocks) exactly as it is on the cart, and the shared maps.lua -- table never keeps a script's edit across a New Game. function World:changeBlock(blockX, blockY, blockId) local map = self.map if not (map and blockX and blockY and blockId) then return false end if blockX < 0 or blockY < 0 or blockX >= map.width or blockY >= map.height then return false end return self:replaceBlock(blockY * map.width + blockX + 1, blockId) end -- Resolve a raw ROM blockdata pointer (bank + address, as a `dba` writes into -- wMapBlocksBank / wMapBlocksPointer) to the blocks a cache actually holds. -- Every map's attributes name the bank and address of its own blockdata, and -- RomExtractorGen2 records that pair as def.blockdata, so a pointer is found by -- the array it lands IN: `address` may sit part way through one, which is how -- the cart names a sub-rectangle of a bigger map's data. Returns the source -- block array and a 0-based offset into it, or nothing when no array covers the -- address (an older cache, whose maps carry no `blockdata`, always answers -- nothing rather than guessing). function World:blockdataAt(bank, address) if not (bank and address) then return nil end for _, def in pairs(self.maps or {}) do local bd = def.blockdata if bd and bd.bank == bank and bd.address and def.blocks and address >= bd.address and address < bd.address + #def.blocks then return def.blocks, address - bd.address end end return nil end -- Script_changemapblocks: a `dba` into wMapBlocksBank / wMapBlocksPointer, then -- ChangeMap and BufferScreen. ChangeMap refills the WHOLE overworld buffer -- from a second copy of the blockdata -- wMapWidth bytes a row for wMapHeight -- rows, read as one flat run off the pointer -- so the amount copied is decided -- by the map standing on screen and not by whichever array the pointer names. -- -- The edits are recorded the way replaceBlock records a CUT so restoreBlocks -- puts the map back on the next load, and BufferScreen is refreshMapImages: a -- baked canvas is keyed by map and daytime and knows nothing about the blocks -- it came from, so leaving it up would keep showing the map as it was first -- baked. -- -- A pointer this cache cannot place is a no-op rather than a guess, and so is a -- run that would read off the end of the source array (the cart would happily -- read into whatever data follows it). Nothing in pokegold reaches the command -- -- 0 hits across maps/ and engine/, it is a Crystal-era path -- so a script -- that does is by definition one this port has never seen. function World:changeMapBlocks(bank, address) local map = self.map local blocks = map and map.def and map.def.blocks if not (map and blocks and map.width and map.height) then return false end local src, offset = self:blockdataAt(bank, address) if not src then return false end local count = map.width * map.height if offset + count > #src then return false end -- Read the run out first: the pointer is allowed to name the loaded map's own -- blockdata, and at a non-zero offset an in-place copy would overwrite the -- bytes it is still reading. local run = {} for i = 1, count do run[i] = src[offset + i] end local edits = self.blockEdits[map.id] if not edits then edits = {} self.blockEdits[map.id] = edits end for i = 1, count do if edits[i] == nil then edits[i] = blocks[i] end blocks[i] = run[i] end map.blocks = blocks self:refreshMapImages() return true end -- Script_earthquake -> ShakeScreen. ONE byte carries two numbers -- (MovementFunction_ScreenShake .GetDurationAndField1e): the low six bits are -- the duration in frames, and the top two pick an amplitude of 1 << bits, which -- StepFunction_ScreenShake adds to and subtracts from wPlayerStepVectorY on -- alternate frames. `earthquake 80` ($50) is therefore two pixels for sixteen -- frames, not eighty of anything. -- -- This starts the shake and returns AT ONCE: the VM holds the script for the -- frames itself, so blocking here would count them twice. function World:earthquake(displacement, frames) local byte = displacement or 0 local amplitude = 2 ^ (math.floor(byte / 64) % 4) self.shake = { left = frames or (byte % 64), amplitude = amplitude, phase = 0 } end function World:updateShake() local shake = self.shake if not shake then return end shake.left = shake.left - 1 if shake.left <= 0 then self.shake = nil return end -- `.GetSign`: the offset flips with the parity of the frames left. shake.phase = (shake.left % 2 == 0) and shake.amplitude or -shake.amplitude end -- engine/events/poisonstep_pals.asm:9 function World:poisonBGFlash() self.poisonFlash = 4 end -- Script_warp / Script_warpfacing: a raw destination CELL, distinct from the -- warp_events World:takeWarp follows. `facing` is nil for `warp` and a -- Movement direction for `warpfacing` (PLAYERSPRITESETUP_CUSTOM_FACING). A -- group/map pair this cache cannot resolve is a silent no-op rather than a -- crash, the same way Script_warp's own group-0 arm goes nowhere. -- -- Script_warp's own entry method is MAPSETUP_WARP, whose script opens on -- DisableLCD rather than on a FadeOutToWhite: the screen goes at once and only -- the way back in is a fade. A `warpfacing` byte is PLAYERSPRITESETUP_CUSTOM_ -- FACING, which SpawnInCustomFacing applies INSTEAD of SpawnInFacingDown, so a -- custom facing skips World:spawnFacing rather than being overridden by it. function World:warpTo(group, mapNum, cellX, cellY, facing) local mapId = self:mapIdByGroupMap(group, mapNum) if not mapId then return false end return self:warpToMapId(mapId, cellX, cellY, facing) end -- The same warp addressed by map id instead of the cart's group/map pair. -- Script code always has the pair; mod.world:warpTo (src/world/gen2/ -- WorldAPI.lua) and anything else holding a maps[] key comes in here, so both -- run one body and a warp from a mod is indistinguishable from a scripted one. function World:warpToMapId(mapId, cellX, cellY, facing) if not (mapId and cellX and cellY) then return false end return self:runMapSetup(MAPSETUP_WARP, function() local ok = self:setMap(mapId, cellX, cellY, facing or (self.player and self.player.facing) or "down") if ok and not facing then self:spawnFacing() end return ok end) end -- Script_warp's group-0 arm: `warp NONE, 0, 0`. wDefaultSpawnpoint is -- SPAWN_N_A, and EnterMapSpawnPoint leaves the map and the coordinates alone -- when it reads that, so MAPSETUP_BADWARP is a full load of the map the player -- is already standing on -- HandleNewMap, LoadBlockData and LoadMapObjects -- included. That is what PlayersHousePCScript's `.Warp` is for: the bedroom's -- decorations only move when the map is loaded again. -- `reason` is the map.reloaded payload's own field and doubles as the emit -- gate: src/world/gen2/WorldAPI.lua's invalidateMap calls this with NO reason -- and raises the event itself with "invalidate", so passing one here is how an -- engine-side reload says "nobody else is announcing this one". Without that -- split a mod calling mod.world:invalidateMap would see the event twice. function World:reloadMapBadWarp(reason) local map = self.map local p = self.player if not (map and p) then return false end local mapId = map.id local cx, cy, facing = p.cellX, p.cellY, p.facing local ok = self:runMapSetup(MAPSETUP_BADWARP, function() return self:setMap(mapId, cx, cy, facing) end) if ok and reason then Runtime.emit("map.reloaded", { mapId = mapId, reason = reason }) end return ok end -- Script_warpcheck -> WarpCheck. It does NOT warp: it notices that the player -- is standing on a warp tile and lets the overworld loop take it once the -- script is done, which is why every use sits at the end of a scripted walk. function World:armWarpCheck() local p = self.player if not (self.map and p) then return false end local entry = self.map:warpAt(p.cellX, p.cellY) if not entry then return false end self.pendingWarp = entry.def return true end -- The drain, from World:step. Deliberately gated on the SCRIPT rather than on -- World:busy: the text box that is still showing the last line belongs to the -- script that armed this, and the cart takes the warp the moment the script -- ends. function World:takePendingWarp() local warp = self.pendingWarp if not warp then return false end self.pendingWarp = nil return self:takeWarp(warp) end -- Script_warpsound -> GetWarpSFX (home/map.asm): which of three sounds a warp -- makes is decided by the tile the player is STANDING on, not by the -- destination. Looked up by name so a cache whose sfx table sits at other -- indices still finds them. local WARP_SFX_NAME = { [SFX_ENTER_DOOR] = "Sfx_EnterDoor", [SFX_WARP_TO] = "Sfx_WarpTo", [SFX_EXIT_BUILDING] = "Sfx_ExitBuilding", } -- Play an sfx by its pokegold LABEL, falling back to the index this cache -- happened to have when the constant above was written. A repointed sfx table -- would otherwise play whatever now sits at the old index. function World:sfxIdNamed(want, fallbackId) local audio = self.game and self.game.data and self.game.data.audio local order = audio and audio.sfxOrder local id = fallbackId if order and want then for i, name in ipairs(order) do if name == want then id = i - 1 break end end end return id end function World:playSfxNamed(want, fallbackId) self:playSfx(self:sfxIdNamed(want, fallbackId)) end -- Script_specialsound (engine/overworld/scripting.asm:476) is not a fixed cue: -- it farcalls CheckItemPocket (engine/items/items.asm:512), which writes -- wCurItem's pocket into wItemAttributeValue, and rings SFX_GET_TM for the -- TM/HM pocket, SFX_ITEM for every other one. It is the sound inside -- GiveItemScript, so every `verbosegiveitem` runs through it -- Sage Li's -- `verbosegiveitem HM_FLASH` and every gym leader's TM included, all of which -- rang the ordinary item jingle while the item argument was thrown away. An -- item the cache cannot name takes the `cp TM_HM / jr z` fall-through, SFX_ITEM. function World:specialSound(itemIndex) local id = itemIndex and self:itemIdByIndex(itemIndex) local items = self.game and self.game.data and self.game.data.items local def = id and items and items[id] if def and def.pocket == "TM_HM" then self:playSfxNamed("Sfx_GetTm", SFX_GET_TM) else self:playSfxNamed("Sfx_Item", SFX_ITEM) end end function World:warpSound() local p = self.player if not (self.map and p) then return end local coll = self.map:cellCollision(p.cellX, p.cellY) local id = SFX_EXIT_BUILDING if coll == COLL_DOOR then id = SFX_ENTER_DOOR elseif coll == COLL_WARP_PANEL then id = SFX_WARP_TO end self:playSfxNamed(WARP_SFX_NAME[id], id) end -- Script_newloadmap: hMapEntryMethod, then MAPSTATUS_ENTER on the CURRENT map. -- The port has one map load, so the reload itself is a setMap onto the cell the -- player is already standing on, which is what puts the magnet train and a link -- return back on their feet; the MAPSETUP_* byte picks which setup script the -- load is wrapped in. -- -- It plays NO sound: Script_newloadmap is four lines and none of them is a -- PlaySFX. Where the cart wants one it writes the separate `warpsound` command -- in front (WarpToNewMapScript is exactly that pair), so inventing one here rang -- a door bell over every scripted re-entry that has none. -- -- A load that a `warpcheck` armed goes to the DESTINATION, not back onto the -- current cell. MapSetupScript_Train opens `mapsetup EnterMapWarp` / -- `mapsetup GetWarpDestCoords` (data/maps/setup_scripts.asm), and EnterMapWarp -- copies wNextWarp / wNextMapGroup / wNextMapNumber -- the triple CopyWarpData -- wrote when warpcheck found the player on a warp tile -- into wWarpNumber and -- the map pair. That pairing is the Magnet Train and nothing else: the two -- station scripts are the only `warpcheck` in the game followed by a -- `newloadmap` rather than by a bare `end`, so every other caller still -- re-enters the map it is standing on. -- -- MapSetupScript_Train has no SpawnInFacingDown, unlike _Warp and _Door, so the -- player steps off the train still facing the way they walked onto it. -- -- The arrival cell is the destination `warp_event`'s own cell and nothing else. -- Both stations land the player in the train doorway at (11,5) while their -- single `coord_event` sits at (11,6), which reads like an off-by-one until the -- block data is checked: TILESET_TRAIN_STATION block $12 is `tilecoll WALL, -- WALL, WALL, DOOR`, so (10,5), (12,5) and (11,4) are all wall and SOUTH is the -- only step there is. Script_ArriveFromGoldenrod is reached by that one forced -- step, the same way the cart reaches it -- EnterMap runs DisableEvents, and -- CheckPlayerState only turns player events back on once a step finishes, so -- no coord event can fire on the load's own frame however the spawn is placed. -- Nudging the spawn onto the coord_event would also walk the player into the -- officer: the arrival movement is `left left down down down down`, which ends -- at (9,10) from (11,6) but at (9,9) from (11,5), and (9,9) is the boarding -- gate the officer's own return movement ends on. -- Locked by tests/gen2_magnet_train_test.lua. function World:newLoadMap(method) local p = self.player if not (self.map and p) then return false end local armed = self.pendingWarp if armed then self.pendingWarp = nil local destMapId, destWarpNumber = self:resolveWarp(armed) local dest = self.maps[destMapId] local destWarp = dest and dest.warps and dest.warps[destWarpNumber] if destWarp then self.backupMapId = self.map.id -- CopyWarpData ran when `warpcheck` found the player on the tile, so -- this take carries the same wPrevWarp bookkeeping as a walked warp. local prevMapId = self.map.id local prevWarpIndex = self:warpIndexOf(armed) return self:runMapSetup(method, function() local ok = self:setMap(destMapId, destWarp.x, destWarp.y, p.facing) if ok then self:recordWarpBackup(prevMapId, prevWarpIndex, destWarp, destMapId) end return ok end) end end return self:runMapSetup(method, function() return self:setMap(self.map.id, p.cellX, p.cellY, p.facing) end) end -- Script_warpmod: wBackupWarpNumber / wBackupMapGroup / wBackupMapNumber, the -- triple that says where the game believes you came IN from. Elevator's -- .FindCurrentFloor and the dig / escape-rope return are what read it back; -- neither exists yet, so STORING it is the whole point. function World:setWarpMod(warpId, group, mapNum) local save = self.game and self.game.save if not save then return end save.warpMod = { warp = warpId, map = self:mapIdByGroupMap(group, mapNum), group = group, mapNumber = mapNum, } end -- Script_blackoutmod: wLastSpawnMapGroup / wLastSpawnMapNumber. The S.S. Aqua -- and Mr. Pokemon's house set it so that losing at sea does not respawn you -- somewhere you cannot leave, so World:warpToSpawn has to prefer it over the -- SPAWN_* landmark lookup, and it has to survive a save. function World:setBlackoutMap(group, mapNum) local save = self.game and self.game.save if not save then return end save.blackoutMap = self:mapIdByGroupMap(group, mapNum) end -- Script_swarm -> StoreSwarmMapIndices, which FALLS THROUGH into SetSwarmFlag: -- 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) 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) end -- Script_loadwildmon's other half: roll the CURRENT map's own table the way a -- step would, and hand it to the startbattle that follows. nil is fine -- -- startScriptedBattle answers "win" rather than hanging when there is no mon. function World:rollWild() local map = self.map if not (map and self.encounters and self.player) then return nil end -- Script_randomwildmon only clears wBattleScriptFlags; the pair `startbattle` -- then fights is whatever last wrote wTempWildMonSpecies / wCurPartyLevel. -- RockMonEncounter is the one routine in this port that writes it ahead of a -- `randomwildmon`, and its mon comes from TREEMON_SET_ROCK rather than from -- the map's grass list, so it is consumed here rather than rolled over. local pending = self.tempWildMon self.tempWildMon = nil if pending then return pending end local tables = self:wildTables() local collision = map:cellCollision(self.player.cellX, self.player.cellY) local onWater = FieldMoves.encounterTable(collision) == "water" -- kind "script": a `randomwildmon` the VM asked for, not a step's roll. local roll = self:rollEncounter("script", onWater and "water" or "grass", tables, onWater and rollWaterVanilla or rollGrassVanilla) if not roll then return nil end -- The encounter tables name a species by ID; a `loadwildmon` pair is a -- SPECIES INDEX, and startScriptedBattle reads the index, so the roll is -- translated here rather than at the battle seam. local pokemon = self.game and self.game.data and self.game.data.pokemon local def = pokemon and pokemon[roll.species] if not (def and def.index) then return nil end return { species = def.index, level = roll.level } end function World:playMapMusic() local data = self.game and self.game.data if data and data.audio and data.audio.runtime and self.map then -- SpecialMapMusic (home/audio.asm:397) Music.playMap(data, self.map.id, nil, FieldMoves.isSurfing(self.playerState)) end end -- RestartMapMusic, which replays wMapMusic -- and wMapMusic is MUSIC_BICYCLE -- for as long as the player is riding (.GetOnBike writes it there). So a -- battle, a hatch or a menu that owned the sound gives the BIKE theme back, -- not the map's own song. function World:restoreMapMusic() local data = self.game and self.game.data if not data then return end if FieldMoves.isBiking(self.playerState) and self:playBikeMusic() then return end Music.restoreMap(data) end -- ForceMapMusic (engine/overworld/map_setup.asm:201), the music row every -- MapSetupScript_ReloadMap ends on (data/maps/setup_scripts.asm:136), and -- TryRestartMapMusic (home/audio.asm:366) under it. wDontPlayMapMusicOnReload -- is consumed HERE, at the reload, not at the end of the battle in front of it: -- every scripted battle is written `startbattle / dontrestartmapmusic / -- reloadmap` (maps/CherrygroveCity.asm:124), so the flag is not even set yet -- while the battle screen is closing. Set, the cart plays MUSIC_NONE, zeroes -- wMapMusic and clears the flag; that zeroed wMapMusic is why the theme does -- not creep back on the next restore either. function World:forceMapMusic() if self.dontRestartMusic then self.dontRestartMusic = false Music.setMapSong(nil) -- `xor a / ld [wMapMusic], a` Music.stop() return end self:restoreMapMusic() end -- Script_musicfadeout: the ramp, and then the song underneath it. The VM has -- already masked MUSIC_FADE_IN_F off the control byte, so `fade` is the number -- of frames the ramp holds each volume step; a musicId of 0 (MUSIC_NONE) is a -- fade to silence and queues nothing. -- -- Music.fadeOut steps rAUDVOL's level 7 -> 0 one notch every `control` frames -- and stops the song at the bottom, so the queued label starts control * 7 -- frames later. Counted here rather than polled, because Music keeps its ramp -- state module-local. function World:fadeOutMusic(musicId, fadeControl) local control = math.max(1, fadeControl or 10) Music.fadeOut(control) local data = self.game and self.game.data local audio = data and data.audio local order = audio and audio.musicOrder local name = order and order[(musicId or 0) + 1] if name and name ~= "Music_Nothing" and audio.songs and audio.songs[name] then self.pendingMusic = { name = name, left = control * 7 } else self.pendingMusic = nil end end -- The fade's tail, ticked from World:step: the queued song starts the frame the -- ramp reaches the bottom, which is what makes a `musicfadeout` read as one -- cross-fade rather than as a cut. function World:updateMusicFade() local pending = self.pendingMusic if not pending then return end pending.left = pending.left - 1 if pending.left > 0 then return end self.pendingMusic = nil local data = self.game and self.game.data if data then Music.play(data, pending.name, true, { reason = "script_fadeout" }) end end -- `checkitem` and `takeitem`, over the same Bag the PACK reads. function World:itemIdByIndex(itemIndex) local items = self.game and self.game.data and self.game.data.items return (itemByIndex(items, itemIndex)) end function World:hasItem(itemIndex) local save = self.game and self.game.save local id = self:itemIdByIndex(itemIndex) if not (save and id) then return false end return (save.inventory and (save.inventory[id] or 0) > 0) or false end function World:takeItem(itemIndex, qty) local save = self.game and self.game.save local id = self:itemIdByIndex(itemIndex) if not (save and id) then return false end save.inventory = save.inventory or {} local have = save.inventory[id] or 0 qty = qty or 1 -- TossItem takes nothing at all when the pack holds fewer than asked. if have < qty then return false end local left = have - qty save.inventory[id] = left > 0 and left or nil return true end -- YOUR_MONEY 0 / MOMS_MONEY 1 (constants/script_constants.asm). The VM does -- the 0..999999 clamp; this is only where the number lives. function World:money(account) local save = self.game and self.game.save if not save then return 0 end if (account or 0) == 1 then return (save.mom and save.mom.savedMoney) or 0 end return (save.player and save.player.money) or 0 end function World:setMoney(account, value) local save = self.game and self.game.save if not save then return end if (account or 0) == 1 then save.mom = save.mom or {} save.mom.savedMoney = value or 0 return end save.player = save.player or {} save.player.money = value or 0 end function World:coins() local save = self.game and self.game.save return (save and save.player and save.player.coins) or 0 end function World:setCoins(value) local save = self.game and self.game.save if not save then return end save.player = save.player or {} save.player.coins = value or 0 end -- `checkpoke`: the PARTY only. CheckPartyOrBoxMon is a different routine and -- no script calls it. function World:hasPoke(speciesIndex) local save = self.game and self.game.save local id = speciesByIndex( self.game and self.game.data and self.game.data.pokemon, speciesIndex) if not (save and id) then return false end for _, mon in ipairs(save.party or {}) do if mon.species == id then return true end end return false end -- `giveegg`: the same builder every other party member goes through, marked -- and counted down the way src/core/gen2/Breeding.lua marks a Day-Care egg, so -- the ODD_EGG and the Togepi egg hatch through DoEggStep like any other. -- False when the party is full; the VM turns true into wScriptVar = 2. function World:giveEgg(speciesIndex, level) local Breeding = require("src.core.gen2.Breeding") local data = self.game and self.game.data local save = self.game and self.game.save if not (data and save) then return false end save.party = save.party or {} if #save.party >= Breeding.PARTY_SIZE then return false end local id, def = speciesByIndex(data.pokemon, speciesIndex) if not id then return false end local mon = Mon.new(data, id, level or Breeding.EGG_LEVEL) if not mon then return false end mon.isEgg = true -- `ld de, String_Egg / call CopyName2`: the slot's nickname IS "EGG", and the -- box list prints it verbatim (engine/pokemon/move_mon.asm:1193-1194, :1220). mon.nickname = Breeding.EGG_NAME -- DayCare_InitBreeding's `ld [hl], EGG_STEPS`: the counter is the species' -- own eggSteps in 256-step cycles, and DayCare_GiveEgg zeroes the HP. mon.eggSteps = (def and def.eggSteps) or 0 mon.hp = 0 -- GiveEgg goes through TryAddMonToParty too (move_mon.asm:1121-1139). Mon.stampOT(save, mon) save.party[#save.party + 1] = mon return true end -- `landmarktotext`: the town-map name of the map the player is on, newline and -- all (landmarks.lua keeps the cart's own two-line names). function World:landmarkName() local id = self:currentLandmarkId() local entry = id and self.landmarks and self.landmarks.landmarks and self.landmarks.landmarks[id] return (entry and entry.name) or nil end -- The fruit trees. data/items/fruit_trees.asm (FruitTreeItems, one byte per -- FRUITTREE_*) is NOT extracted -- nothing in the ROM's bytecode points at it, -- the same reason FruitTreeScript itself had to be transcribed into the VM -- -- so the table lives in src/core/gen2/Apricorns.lua beside the seven apricorn -- trees it feeds, and this is the half that turns its item ids into the -- indices the script side speaks in. -- -- Which trees have been picked is per-day state on the save, and it is -- Apricorns.checkDailyResetTimer (World:checkTimeEvents) that clears -- ENGINE_ALL_FRUIT_TREES overnight so TryResetFruitTrees will refill them. function World:fruitTreeItem(treeId) -- FRUITTREE_* is 1-based (`const_def 1`), which is also how a Lua array -- indexes, so the id needs no shift: GetCurTreeFruit's `dec a` is the cart -- converting the same id into a 0-based offset. local id = Apricorns.treeFruit(treeId) local items = self.game and self.game.data and self.game.data.items local def = id and items and items[id] return (def and def.index) or 0 end -- callasm TryResetFruitTrees, at the top of FruitTreeScript: the FIRST tree -- examined after the daily rollover refills every tree in the game at once. function World:fruitTreeReset() local save = self.game and self.game.save if not save then return false end return Apricorns.tryResetFruitTrees(save) end function World:fruitTreePicked(treeId) local save = self.game and self.game.save return save and Apricorns.treePicked(save, treeId) or false end function World:fruitTreePick(treeId) local save = self.game and self.game.save if not (save and treeId) then return end Apricorns.pickTree(save, treeId) end -- `askforphonenumber`. Phone.addContact is the whole rule: _CheckCellNum runs -- first and returns the same carry for "already stored" as for "full", so both -- refusals come back as false here. function World:addPhoneNumber(contact) local Phone = require("src.core.gen2.Phone") local save = self.game and self.game.save if not (save and contact) then return false end if Phone.hasContact(save, contact) then return false end return Phone.addContact(save, contact) and true or false end -- `specialphonecall` / `checkphonecall`: wSpecialPhoneCallID, which the next -- Phone.checkSpecialCall consumes. function World:setSpecialCall(id) local Phone = require("src.core.gen2.Phone") local save = self.game and self.game.save if not save then return end if (id or 0) == Phone.SPECIALCALL_NONE then Phone.clearSpecialCall(save) else Phone.queueSpecialCall(save, id) end end function World:specialCall() local Phone = require("src.core.gen2.Phone") local save = self.game and self.game.save return save and Phone.specialCallVar(save) or 0 end -- ---- the specials' world half ---------------------------------------------- -- -- data/events/special_pointers.asm is 112 routines and most of them are one -- reach into the world apiece, so they share one table rather than one `xFn` -- field on the VM each. src/script/gen2/Specials.lua is the other half: every -- handler there is the cart routine, and everything it cannot do without the -- game (a screen, the save, the party) is a call into here. -- FadeToMenu / ExitAllMenus wrap a dozen specials. A screen id that is not -- registered in src/ui/Screens.lua yet must not take the game down with it, so -- every push goes through here: an unknown id is a no-op that answers false, -- and the special's own degrade takes over. function World:pushScreen(id, opts) local game = self.game if not (game and game.stack) then return false end local ok = pcall(Screens.push, game, id, opts) return ok end -- SelectMonFromParty (engine/pokemon/party_menu.asm), the one blocking piece a -- dozen specials share: BillsGrandfather, the two haircut brothers, Daisy, -- CheckMagikarpLength, ReturnShuckie and the move deleter all open the same -- list and all read wCurPartySpecies out of it. `onDone(index, mon)` with nil -- for the B press, exactly the shape the ASM's carry flag has. function World:selectPartyMon(prompt, onDone) local game = self.game local save = game and game.save if not (game and game.stack and save and save.party and #save.party > 0) then if onDone then onDone(nil) end return false end local finished = false local function finish(index, mon) if finished then return end finished = true game.stack:pop() if onDone then onDone(index, mon) end end local ok = self:pushScreen("Gen2PartyMenu", { save = save, party = save.party, prompt = prompt or "choose", onChoose = function(index, mon) finish(index, mon) end, onCancel = function() finish(nil, nil) end, }) if not ok then if onDone then onDone(nil) end return false end return true end -- GivePokeMail (engine/pokemon/mail.asm), the `givepokemail` opcode's body: -- the letter behind the script's pointer is hung on the LAST party member, the -- one the `givepoke` immediately before it just added. One call site in the -- whole game -- RandyScript in maps/Route35GoldenrodGate.asm, KENYA's -- FLOWER_MAIL -- and the extractor resolves the operand into -- { item = "FLOWER_MAIL", message = "..." } (RomExtractorGen2). -- -- A cache built before that resolution leaves the raw pointer word instead; -- there is no way to invent the item or the message from a number, so nothing -- is given rather than a blank letter being hung on the mon. function World:givePokeMail(mail) local save = self.game and self.game.save if not (save and type(mail) == "table" and mail.item) then return false end return Mail.give(save, mail.item, mail.message) end -- CheckPokeMail (engine/pokemon/mail.asm): the party list, then the five-way -- answer, then -- on POKEMAIL_CORRECT only -- the mon leaving the party for -- good. The rules are src/core/gen2/Mail.lua; this is the list and the -- wScriptVar the VM parks on. -- -- The one call site is Route31MailRecipientScript, whose `ifequal` ladder -- covers all five values, so answering the wrong one picks a random branch of -- somebody's quest. Backing out of the list is REFUSED and an unresolved -- expected message is WRONG_MAIL: both leave the mon exactly where it was. function World:checkPokeMail(mail, onDone) local save = self.game and self.game.save local expected = (type(mail) == "table") and mail.message or nil -- selectPartyMon answers onDone(nil) AND returns false when it cannot open a -- list at all, so the resume is guarded: a second one would drive the script -- coroutine twice off one opcode. local answered = false local function answer(value) if answered then return end answered = true if onDone then onDone(value) end end if not save then return answer(Mail.POKEMAIL_REFUSED) end local ok = self:selectPartyMon("choose", function(index) answer(Mail.checkPokeMail(save, index, expected)) end) if not ok then answer(Mail.POKEMAIL_REFUSED) end end -- The Day-Care conversation, all three doors of it. The model is -- src/core/gen2/Breeding.lua and the screen is src/ui/gen2/DayCareMenu.lua; -- this is only the push, and the scriptVar the outside man's branch answers -- with (TRUE = "no room, come back") rides back through onDone. function World:dayCare(side, onDone) local game = self.game if not (game and game.stack) then if onDone then onDone(0) end return false end local finished = false local function finish(scriptVar) if finished then return end finished = true game.stack:pop() if onDone then onDone(scriptVar or 0) end end local ok = self:pushScreen("Gen2DayCareMenu", { save = game.save, side = side, -- text.lua carries the whole Day-Care block, seeded by name. text = self.text, onClose = finish, }) if not ok then if onDone then onDone(0) end return false end return true end -- ChooseMoveToDelete (engine/pokemon/mon_menu.asm), the move-list half of the -- Blackthorn move deleter (src/script/gen2/Specials.lua H.MoveDeletion). The -- caller has already refused a mon with only one move, so this only has to -- put the list up and hand back a 1-based slot or nil for B. function World:chooseMoveToDelete(mon, onDone) local game = self.game if not (game and game.stack) then if onDone then onDone(nil) end return false end local finished = false local function finish(index) if finished then return end finished = true game.stack:pop() if onDone then onDone(index) end end local ok = self:pushScreen("Gen2MoveDeleter", { mon = mon, moves = game.data and game.data.moves, onChoose = function(index) finish(index) end, onCancel = function() finish(nil) end, }) if not ok then if onDone then onDone(nil) end return false end return true end -- Mom_SetUpWithdrawMenu / Mom_SetUpDepositMenu / Mom_WithdrawDepositMenuJoypad -- (engine/events/mom.asm), the six-digit money keypad BankOfMom's GET and SAVE -- both put up. `kind` is "deposit" or "withdraw", only for the screen's own -- DEPOSIT@/WITHDRAW@ label; `onDone(amount)` gets the typed 0..999999 or nil -- for B, and H.BankOfMom (src/script/gen2/Specials.lua) is what turns that -- into an actual GiveMoney/TakeMoney pair against the two accounts. function World:bankOfMomAmount(kind, saved, held, onDone) local game = self.game if not (game and game.stack) then if onDone then onDone(nil) end return false end local finished = false local function finish(amount) if finished then return end finished = true game.stack:pop() if onDone then onDone(amount) end end local ok = self:pushScreen("Gen2BankOfMom", { kind = kind, saved = saved, held = held, onDone = function(amount) finish(amount) end, onCancel = function() finish(nil) end, }) if not ok then if onDone then onDone(nil) end return false end return true end -- The rename half of the Goldenrod NAME RATER (engine/events/name_rater.asm, -- src/script/gen2/Specials.lua H.NameRater). Same keyboard World:nameHatchling -- opens for a freshly-hatched egg, but the header is the species name loaded -- by GetBaseData (`ld b, NAME_MON / ld de, wStringBuffer2 / farcall -- _NamingScreen`), not a fixed prompt -- BoxMenu:askNickname's screen is the -- same shape for the same reason. `onDone(name)` gets the typed string or nil -- for B; IsNewNameEmpty/CompareNewToOld both live in the special, not here. -- SetDayOfWeek's wheel (src/ui/gen2/InitClock.lua day mode). `onDone(day)` is -- the special's own resume, the same shape World:nameRival hands H.NameRival: -- the screen's close is what starts the script again. function World:setDayOfWeek(onDone) local game = self.game if not (game and game.stack) then if onDone then onDone(nil) end return false end local ok = self:pushScreen("Gen2InitClock", { mode = "day", save = game.save, onDone = function(day) game.stack:pop() if onDone then onDone(day) end end, }) if not ok and onDone then onDone(nil) end return ok end -- `opts.blank` is the fresh-catch entry: GiveANickname_YesNo's keyboard opens -- empty (wMonOrItemNameBuffer holds the species name for InitNickname to copy -- back, it is not typed into the field), where the Name Rater's opens on the -- name it is replacing. function World:renameMon(mon, onDone, opts) local game = self.game if not (game and game.stack and mon) then if onDone then onDone(nil) end return false end local finished = false local function finish(name) if finished then return end finished = true game.stack:pop() if onDone then onDone(name) end end local ok = self:pushScreen("Gen2NamingScreen", { type = "nickname", monName = mon.name or mon.species, initial = (opts and opts.blank) and "" or (mon.nickname or mon.name or mon.species or ""), onDone = function(name) finish(name) end, onCancel = function() finish(nil) end, }) if not ok then if onDone then onDone(nil) end return false end return true end -- The #DEX-completion diploma (engine/events/diploma.asm, src/ui/gen2/ -- Diploma.lua, src/script/gen2/Specials.lua H.Diploma). `special Diploma` -- itself never writes wScriptVar, so `onDone` here takes no argument -- it is -- only the "the player pressed A or B, close and carry on" signal the -- coroutine in Specials.block is parked on. function World:showDiploma(onDone) local game = self.game if not (game and game.stack) then if onDone then onDone() end return false end local finished = false local function finish() if finished then return end finished = true game.stack:pop() if onDone then onDone() end end local ok = self:pushScreen("Gen2Diploma", { playerName = game.save and game.save.player and game.save.player.name, onClose = finish, }) if not ok then if onDone then onDone() end return false end return true end -- The Magnet Train ride (engine/events/magnet_train.asm, src/core/gen2/ -- MagnetTrain.lua, src/ui/gen2/MagnetTrainRide.lua, src/script/gen2/ -- Specials.lua H.MagnetTrain). `special MagnetTrain` never writes wScriptVar -- -- it READS the one the officer's `setval` left there -- so `onDone` takes -- no argument and is only the "the cutscene reached JUMPTABLE_EXIT" signal the -- coroutine in Specials.block is parked on. The `warpcheck` and the -- `newloadmap MAPSETUP_TRAIN` that follow it are the script's, not this. function World:magnetTrain(toGoldenrod, onDone) local game = self.game if not (game and game.stack) then if onDone then onDone() end return false end local finished = false local function finish() if finished then return end finished = true game.stack:pop() if onDone then onDone() end end local ok = self:pushScreen("Gen2MagnetTrainRide", { toGoldenrod = toGoldenrod, onDone = finish, }) if not ok then if onDone then onDone() end return false end return true end -- The Cianwood photo studio's portrait card (engine/printer/print_party.asm -- PrintPartyMonPage1, src/ui/gen2/PhotoStudio.lua, src/script/gen2/ -- Specials.lua H.PhotoStudio). `onDone` takes no argument, the same as -- showDiploma above -- it is only the "player pressed A or B, close and -- carry on" signal Specials.block is parked on. function World:showPhotoStudio(mon, onDone) local game = self.game if not (game and game.stack) then if onDone then onDone() end return false end local finished = false local function finish() if finished then return end finished = true game.stack:pop() if onDone then onDone() end end local ok = self:pushScreen("Gen2PhotoStudio", { mon = mon, playerName = game.save and game.save.player and game.save.player.name, onClose = finish, }) if not ok then if onDone then onDone() end return false end return true end -- The ALPH RUINS STAMP viewer (engine/events/print_unown.asm _UnownPrinter, -- src/ui/gen2/UnownPrinter.lua, src/script/gen2/Specials.lua -- H.UnownPrinter). Same shape as showPhotoStudio above, and for the same -- reason: the screen is the whole special, and `onDone` is only the "B was -- pressed, close and carry on" signal Specials.block is parked on. function World:showUnownPrinter(onDone) local game = self.game if not (game and game.stack) then if onDone then onDone() end return false end local finished = false local function finish() if finished then return end finished = true game.stack:pop() if onDone then onDone() end end local ok = self:pushScreen("Gen2UnownPrinter", { onClose = finish }) if not ok then if onDone then onDone() end return false end return true end -- PostCreditsSpawn (engine/menus/intro_menu.asm): read wSpawnAfterChampion, -- clear it, and answer the spawn point CONTINUE warps to instead of the saved -- position -- SPAWN_NEW_BARK after the Elite Four, SPAWN_MT_SILVER after Red. -- HallOfFame.consumePostGameSpawn owns the byte and the mapping; this half -- resolves the SPAWN_* row against the extracted landmarks table, and answers -- nil both for an ordinary continue and for a cache whose spawn cannot be -- found, which leaves the saved position in charge exactly as `xor a` does. function World:consumePostGameSpawn() local save = self.game and self.game.save local spawnId = HallOfFame.consumePostGameSpawn(save) if not spawnId then return nil end local spawn = self.landmarks and self.landmarks.spawns and self.landmarks.spawns[spawnId] if not (spawn and spawn.map and self.maps and self.maps[spawn.map]) then return nil end return spawn end -- `halloffame` ($9f) -> Script_halloffame (engine/overworld/scripting.asm), -- which stops the game timer and farcalls HallOfFame -- (engine/events/halloffame.asm). That routine is TWO screens, not one: -- -- ld a, [wStatusFlags] ; the PRE-induction flags -- push af -- ...set STATUSFLAGS_HALL_OF_FAME_F, bump the count, SaveGameData, -- GetHallOfFameParty, AddHallOfFameEntry... -- call AnimateHallOfFame -- pop af -- jp Credits -- -- so the roster ceremony runs and then falls straight into the credits, and -- the `a` Credits receives is the copy pushed BEFORE the bit was set. Credits -- reads exactly that bit (`bit STATUSFLAGS_HALL_OF_FAME_F, b`) to decide -- ALLOW_SKIPPING_CREDITS_F, which is why a first-time champion has to sit -- through the roll and a repeat one can hold B: the answer has to be sampled -- before the induction or every champion after the first would look like the -- first. HallOfFame.induct returns that pre-value second for this reason. -- -- The save really does happen inside the ceremony (farcall SaveGameData), so -- it goes through the same writer SaveMenu uses rather than waiting for the -- player to save afterwards. function World:hallOfFame(onDone) local game = self.game local save = game and game.save if not (game and game.stack and save) then if onDone then onDone() end return false end -- SaveGameData saves the whole of sPlayerData, not just the roster, so the -- live world has to be folded in first: wEventFlags, the wSceneID block -- and wPlayerState are read back by World:loadPlayerData, and a write that -- skipped the snapshot would hand the next CONTINUE the flags of whenever -- the player last stood in front of a SAVE menu. local _, wasEntered = HallOfFame.induct(save, save.party, { saveFn = function(data) if game.snapshotSave then pcall(game.snapshotSave, game) end pcall(Gen2Save.save, data) end, }) local finished = false local function finish() if finished then return end finished = true game.stack:pop() if onDone then onDone() end -- ReturnFromCredits (engine/overworld/scripting.asm) is Script_endall -- plus MAPSTATUS_DONE: the script and the overworld loop both end. And -- FinishContinueFunction (engine/menus/intro_menu.asm) answers anything -- but SPAWN_RED in wSpawnAfterChampion with `jp Reset`, so the champion's -- credits end on the title screen -- the induction's own SaveGameData is -- already on disk with SPAWN_LANCE in it, and the next CONTINUE spawns at -- New Bark Town through World:consumePostGameSpawn. if game.returnToTitle then game:returnToTitle() end end -- `pop af / jp Credits`: the roll follows the ceremony on the same call, and -- only the credits' own end returns to the script. local function toCredits() game.stack:pop() local ok = self:pushScreen("Gen2Credits", { allowSkip = wasEntered, onDone = finish, }) if not ok then finished = true if onDone then onDone() end end end local ok = self:pushScreen("Gen2HallOfFame", { save = save, mode = "induct", -- text.lua carries the three header strings the extractor seeds by name. text = self.text, onDone = toCredits, }) if not ok then if onDone then onDone() end return false end return true end -- `credits` ($a0) -> Script_credits, a bare `farcall RedCredits`. RedCredits -- is the post-Red roll and skips the ceremony entirely: it fades to white, -- sets SPAWN_RED (not SPAWN_LANCE, which is the whole difference between the -- two endings' CONTINUE) and reaches Credits with `ld a, [wStatusFlags]` read -- LIVE. Anyone who has beaten Red has necessarily entered the Hall of Fame -- already, so that live read is why this roll is always skippable while the -- champion's first one is not. function World:credits(onDone) local game = self.game local save = game and game.save if not (game and game.stack) then if onDone then onDone() end return false end HallOfFame.markRedCredits(save) local finished = false local function finish() if finished then return end finished = true game.stack:pop() if onDone then onDone() end -- FinishContinueFunction's .AfterRed (engine/menus/intro_menu.asm): -- SPAWN_RED is the one wSpawnAfterChampion value that does not `jp -- Reset`. SpawnAfterRed writes wDefaultSpawnpoint = SPAWN_MT_SILVER, -- PostCreditsSpawn clears the byte, and the loop re-enters the overworld -- through MAPSETUP_WARP -- play resumes outside Silver Cave, in session, -- with no trip through the title screen. local spawn = self:consumePostGameSpawn() if spawn then self:runMapSetup(MAPSETUP_WARP, function() return self:setMap(spawn.map, spawn.x, spawn.y, "down") end) end end local ok = self:pushScreen("Gen2Credits", { allowSkip = HallOfFame.hasEntered(save), onDone = finish, }) if not ok then if onDone then onDone() end return false end return true end -- The two Game Corner machines. Both are StartGameCornerGame, which is -- CheckCoinsAndCoinCase and then the game itself; the coin-case refusal is -- transcribed in Specials because it prints text the VM already knows how to -- show, so this is only the push. function World:gameCornerGame(kind, onDone) local game = self.game if not (game and game.stack) then if onDone then onDone() end return false end local id = (kind == "cardflip") and "Gen2CardFlip" or "Gen2SlotMachine" local finished = false local function finish() if finished then return end finished = true game.stack:pop() if onDone then onDone() end end local ok = self:pushScreen(id, { save = game.save, onClose = finish }) if not ok then if onDone then onDone() end return false end return true end -- The Ruins of Alph sliding-panel puzzle. `special UnownPuzzle` is -- FadeToMenu / _UnownPuzzle / `ld a, [wSolvedUnownPuzzle] / ld [wScriptVar], a` -- / ExitAllMenus, so the only thing this owes the script is the screen and the -- one byte it answers with. -- -- `puzzleId` is the UNOWNPUZZLE_* the chamber's `setval` parked in wScriptVar -- just before the special, which is also what LoadUnownPuzzlePiecesGFX masks to -- pick the picture. A push that cannot happen answers "not solved" rather than -- leaving the script parked forever. function World:unownPuzzle(puzzleId, onDone) local game = self.game if not (game and game.stack) then if onDone then onDone(false) end return false end local finished = false local function finish(solved) if finished then return end finished = true game.stack:pop() if onDone then onDone(solved and true or false) end end local ok = self:pushScreen("Gen2UnownPuzzle", { puzzle = puzzleId or 0, save = game.save, onClose = finish, }) if not ok then if onDone then onDone(false) end return false end return true end -- SurfStartStep (engine/overworld/player_object.asm): the player goes into -- PLAYER_SURF and takes one step forward onto the water. Called by the -- special rather than by the field move, because Script_UsedSurf's `special -- SurfStartStep` is what actually puts them on the Lapras. -- SurfStartStep (engine/overworld/player_object.asm) is the `special` half of -- the same three lines World:runSurf runs when SURF is chosen from the party -- menu: the state change, the map's surfing theme, and one scripted step off -- the bank. Script_UsedSurf calls it, so both routes have to land in the same -- place -- if they did not, surfing from a script would leave the player -- walking on water. function World:surfStartStep(mon) local p = self.player if not p then return false end self:applyPlayerState(FieldMoves.surfType(mon)) local audio = self.game and self.game.data and self.game.data.audio if audio and audio.runtime and self.map then -- SpecialMapMusic (home/audio.asm:397) Music.playMap(self.game.data, self.map.id, nil, FieldMoves.isSurfing(self.playerState)) end if p.scriptStep then p:scriptStep(p.facing) end self.fieldMove = { phase = "step" } return true end -- The eight ROM-0 presentation specials (FadeOutToWhite .. UpdatePlayerSprite). -- None of them is state: they are the fade, the palette reload and the sprite -- refresh a scripted cutscene brackets itself with. The port has one map -- image and rebuilds people from one list, so `sprites` and `palettes` are a -- rebuild and a re-bake, and the fades are a flat overlay World:draw honours. function World:screenFade(kind) -- kind: "outWhite" | "outBlack" | "inWhite" | "inBlack" if kind == "inWhite" or kind == "inBlack" then self.fade, self.fadeLevel = nil, nil return end self.fade = (kind == "outWhite") and "white" or "black" self.fadeLevel = 1 end -- RunMapSetupScript (engine/overworld/map_setup.asm): every map entry runs one -- of the eleven MapSetupScripts, and the load itself sits in the MIDDLE of it. -- The port loads a map in a single World:setMap call, so what is left of the -- script is the pair of fades it is wrapped in -- and running the load between -- them rather than instead of them is the whole difference between a door that -- opens and a cut. -- -- The chain OWNS the frames it runs for: World:busy() is true throughout, which -- is what stops a still-held direction from stepping the player on the far side -- before the map's own deferred scene script gets to run. On the cart that -- falls out of the setup script being a blocking call inside the overworld -- loop; here it has to be said out loud. function World:runMapSetup(method, load) -- JumpRoamMons sits above the load in MapSetupScript_Teleport; UpdateRoamMons -- below it in _Connection and _Train. Wrapping the load rather than editing -- setMap keeps both on the right side of it, and keeps the roam walk where -- the cart puts it -- in the setup SCRIPT, not in the map load. self:roamMonsBeforeLoad(method) local wrapped = function() local ok = load() self:roamMonsAfterLoad(method) return ok end if MAPSETUP_NO_FADE[method] then return wrapped() end if not MAPSETUP_FADE_OUT[method] then -- MAPSETUP_WARP and friends open on DisableLCD: the screen simply goes, and -- only the way back in is a fade. local ok = wrapped() self.fade, self.fadeLevel = "white", 1 self.mapSetup = { phase = "in", step = FADE_STEPS, wait = FADE_STEP_FRAMES } return ok end self.mapSetup = { phase = "out", step = 0, wait = FADE_STEP_FRAMES, load = wrapped, } return true end -- --------------------------------------------------------------------------- -- The three roaming beasts (engine/overworld/wildmons.asm). -- --------------------------------------------------------------------------- -- -- src/core/gen2/Roamers.lua is the whole model and the ONE writer of -- save.roamers -- the walk, the encounter roll, the HP bank and the flee -- tables. Everything here is a call site, because until now there were none: -- `special InitRoamMons` put the three structs on the save when the Burned -- Tower floor gave way and then nothing ever moved them, rolled for them or -- banked them, so a beast sat on its starting route forever and could not be -- met even there. -- -- The four sites are the four the cart has: two map setup commands, one gate at -- the top of ChooseWildEncounter, and BattleEnd_HandleRoamMons. -- Random(n) for the roam walk. Injectable so a test and a driver can pin it; -- nil means the ambient stream, which is what the game plays on. function World:roamRandom() return self.roamerRandom end function World:roamMonsBeforeLoad(method) if not MAPSETUP_ROAM_JUMP[method] then return false end local save = self.game and self.game.save if not (save and Roamers.list(save)) then return false end -- The map the player is LEAVING: JumpRoamMons runs above the load. return Roamers.jumpAll(save, self.map and self.map.id, self:roamRandom(), self.encounters) end -- The CONTINUE menu's own `farcall JumpRoamMons`, which is not a map setup -- script at all: it fires once per load of a save file, from World:load, with -- no map yet built -- so the map the scatter avoids has to be passed in. function World:roamMonsOnContinue(playerMapId) local save = self.game and self.game.save if not (save and Roamers.list(save)) then return false end return Roamers.jumpAll(save, playerMapId, self:roamRandom(), self.encounters) end function World:roamMonsAfterLoad(method) if not MAPSETUP_ROAM_UPDATE[method] then return false end local save = self.game and self.game.save if not (save and Roamers.list(save)) then return false end -- The map the player has ARRIVED on: UpdateRoamMons is the script's tail. return Roamers.update(save, self.map and self.map.id, self:roamRandom(), self.encounters) end -- BattleEnd_HandleRoamMons. A roaming battle banks the beast's HP and moves -- it (or clears the slot if it was caught or beaten); ANY other wild battle -- takes the `.not_roaming` tail, which is a 1-in-16 roll that moves them all -- -- which is why the beasts drift while you grind, not only while you walk. function World:roamMonsAfterBattle(roaming, outcome, enemyHp) local save = self.game and self.game.save if not (save and Roamers.list(save)) then return false end local mapId = self.map and self.map.id if roaming then return Roamers.endBattle(save, roaming, outcome, enemyHp, mapId, self:roamRandom(), self.encounters) end return Roamers.afterWildBattle(save, mapId, self:roamRandom(), self.encounters) end function World:updateMapSetup() local ms = self.mapSetup ms.wait = ms.wait - 1 if ms.wait > 0 then return end ms.wait = FADE_STEP_FRAMES if ms.phase == "out" then ms.step = ms.step + 1 self.fade, self.fadeLevel = "white", ms.step / FADE_STEPS -- FlyFromAnim carries the player up and off the map under the fade. The -- bird's own frames are not in the cache, but the lift is: it is the same -- OBJECT_SPRITE_Y_OFFSET sine the teleport step type walks -- (src/script/gen2/Movement.lua), stepped over the fade's four levels. if ms.lift and self.player then self.player.spriteYOffset = Movement.teleportYOffset( Movement.TELEPORT_RISE_HEIGHT + ms.step * FADE_STEPS) end if ms.step >= FADE_STEPS then -- setMap clears self.fade (a map load repaints everything), so the sheet -- has to be re-armed at full strength on the far side for the fade in to -- take back down. ms.load() ms.phase = "in" self.fade, self.fadeLevel = "white", 1 end return end ms.step = ms.step - 1 -- FlyToAnim, the same curve read backwards: the player comes down onto the -- destination tile as the fade lets go of the screen. if ms.lift and self.player then self.player.spriteYOffset = Movement.teleportYOffset( Movement.TELEPORT_RISE_HEIGHT + ms.step * FADE_STEPS) end if ms.step <= 0 then self.fade, self.fadeLevel = nil, nil self.mapSetup = nil if ms.lift and self.player then self.player.spriteYOffset = 0 end return end self.fadeLevel = ms.step / FADE_STEPS end function World:reloadSprites(withPalettes) self:rebuildPeople({ seamless = true }) if withPalettes ~= false then self:applyPalettes() end end -- Every hook Specials.lua may reach for, in one place so the module's whole -- surface is readable at a glance and a test can stub it wholesale. function World:specialHooks() return { world = self, healParty = function() self:healParty() end, warpToSpawn = function() self:warpToSpawn() end, openPc = function() self:openPc() end, -- _PlayersHousePC: the same screen with the DECORATION row, and an answer. playersHousePc = function(onDone) self:openPc({ house = true, onDone = onDone }) end, toggleDecorationsVisibility = function() self:toggleDecorationsVisibility() end, toggleMaptileDecorations = function() self:toggleMaptileDecorations() end, nameRival = function(onDone) self:nameRival(onDone) end, playSfx = function(id) self:playSfx(id) end, -- The same sound by its pokegold LABEL, for a handler porting a `ld de, -- SFX_x / call PlaySFX` pair: the Gold sfx table is keyed by label, and an -- index written down in Lua is only right until the table moves. playSfxNamed = function(name, fallbackId) self:playSfxNamed(name, fallbackId) end, playCry = function(index) self:playCry(index) end, playMapMusic = function() self:playMapMusic() end, restartMapMusic = function() self:restoreMapMusic() end, fadeOutMusic = function() Music.fadeOut(2) end, stopMusic = function() Music.stop() end, currentMusic = function() return Music.current() end, fade = function(kind) self:screenFade(kind) end, reloadSprites = function(withPalettes) self:reloadSprites(withPalettes) end, updatePlayerSprite = function() self:applyPlayerState(self.playerState) end, surfStartStep = function(mon) return self:surfStartStep(mon) end, save = function() return self.game and self.game.save end, data = function() return self.game and self.game.data end, party = function() local save = self.game and self.game.save return (save and save.party) or {} end, playerCell = function() local p = self.player return p and p.cellX or 0, p and p.cellY or 0 end, mapId = function() return self.map and self.map.id end, coins = function() return self:coins() end, setCoins = function(value) self:setCoins(value) end, money = function(account) return self:money(account) end, setMoney = function(account, value) self:setMoney(account, value) end, -- Mom_SetUpWithdrawMenu / Mom_SetUpDepositMenu's six-digit money keypad -- (src/script/gen2/Specials.lua H.BankOfMom). `onDone` gets the typed -- amount or nil for B; the special itself owns the balance checks that -- follow, the same split ChooseMoveToDelete keeps with H.MoveDeletion. bankOfMomAmount = function(kind, saved, held, onDone) return self:bankOfMomAmount(kind, saved, held, onDone) end, hasItem = function(index) return self:hasItem(index) end, 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, 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, gameCornerGame = function(kind, onDone) self:gameCornerGame(kind, onDone) end, unownPuzzle = function(puzzleId, onDone) self:unownPuzzle(puzzleId, onDone) end, selectPartyMon = function(prompt, onDone) self:selectPartyMon(prompt, onDone) end, chooseMoveToDelete = function(mon, onDone) self:chooseMoveToDelete(mon, onDone) end, renameMon = function(mon, onDone, opts) self:renameMon(mon, onDone, opts) end, setDayOfWeek = function(onDone) self:setDayOfWeek(onDone) end, showDiploma = function(onDone) self:showDiploma(onDone) end, showPhotoStudio = function(mon, onDone) self:showPhotoStudio(mon, onDone) end, showUnownPrinter = function(onDone) self:showUnownPrinter(onDone) end, magnetTrain = function(toGoldenrod, onDone) self:magnetTrain(toGoldenrod, onDone) end, pushScreen = function(id, opts) return self:pushScreen(id, opts) end, monName = function(index) local id, def = speciesByIndex( self.game and self.game.data and self.game.data.pokemon, index) return (def and def.name) or id end, monIndex = function(species) local pokemon = self.game and self.game.data and self.game.data.pokemon local def = pokemon and pokemon[species] return def and def.index or nil end, -- The item pair, the way monName / monIndex are the species pair: a -- handler that builds a menu needs the printed name, and one that leaves -- an item in wScriptVar needs the CONSTANT the following `ifequal` ladder -- compares against (Kurt's is `ifequal BLU_APRICORN`). itemName = function(id) local items = self.game and self.game.data and self.game.data.items local def = items and items[id] return (def and def.name) or id end, itemIndex = function(id) local items = self.game and self.game.data and self.game.data.items local def = items and items[id] return def and def.index or nil end, -- The same static menu `loadmenu` / `verticalmenu` opens, for the handlers -- whose menu is compiled into a routine instead of into a MenuHeader the -- extractor can follow (Kurt_SelectApricorn builds its rows at run time -- out of the pack). scriptMenu = function(header, onChoose) self:openScriptMenu(header, "vertical", onChoose) end, rareWildMon = function() return self:rareWildMon() end, } 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 -- COMMONEST -- which is what stops the caller reporting a Rattata as a rarity. -- The time of day picks which of the three lists is read, exactly as a step -- would. function World:rareWildMon() local map = self.map local entry = self.encounters and self.encounters.grass and map and self.encounters.grass[map.id] if not entry or not entry.slots then return nil end local key = (self.daytime == "DARK") and "NITE" or (self.daytime or "DAY") local slots = entry.slots[key] or entry.slots.DAY if not slots then return nil end local rare = slots[4 + math.random(3)] if not (rare and rare.species) then return nil end for i = 1, 4 do local common = slots[i] if common and common.species == rare.species then return nil end end return rare.species end function World:objectEntity(objectId) if objectId == 0 then return self.player end -- object_const_def starts at 2; extracted objects are 1-based. local index = (objectId or 0) - 1 if index < 1 then return self.talkNpc end for _, npc in ipairs(self.npcs) do if npc.def and npc.def.index == index then return npc end end return nil end function World:turnObject(objectId, facing) local ent = self:objectEntity(objectId) if ent and ent.scriptFace then ent:scriptFace(facing) end end function World:disappearObject(objectId) local index = (objectId or 0) - 1 local def = self.map and self.map.def local obj = def and def.objects and def.objects[index] if not obj then return end -- Script_disappear does TWO separate stores, and only one of them is the -- flag. DeleteObjectStruct -> MaskObject (home/map_objects.asm:347, -- home/map.asm:1542) writes the byte of wObjectMasks for THIS object and -- nothing else, which is what takes it off the map now; the flag -- ApplyEventActionAppearDisappear sets afterwards is only what the next -- LoadObjectMasks reads back at map load. Objects sharing one -- MAPOBJECT_EVENT_FLAG are ordinary (maps/BurnedTowerB1F.asm:152 gives all -- three animated beasts EVENT_BURNED_TOWER_B1F_BEASTS_1 and jumps them away -- one at a time), so the mask is per object even when the flag is not. -- -- ApplyEventActionAppearDisappear returns WITHOUT touching anything when the -- flag word is -1 ($ffff), and MaskObject has already pulled the struct by -- then -- so an object with no real flag still vanishes for the rest of the -- map's visit. The port's Events:objectVisible reads $ffff as "always -- visible", so setting it would have been a no-op; the mask is what hides -- the flagless ones, and the next load re-derives every mask from the flags. if obj.eventFlag and obj.eventFlag ~= 0xFFFF then self.events:set(obj.eventFlag, true) end self:setObjectMask(obj, index, true) self:rebuildPeople({ seamless = true }) end -- StartFollow: the follower's movement type becomes SPRITEMOVEDATA_FOLLOWING -- and it walks the leader's own path one cell behind. Only one pair exists at -- a time (wObjectFollow_Leader / _Follower are single bytes), so a second -- `follow` replaces the first. function World:startFollow(leaderId, followerId) local leader = self:objectEntity(leaderId) local follower = self:objectEntity(followerId) if not (leader and follower) or leader == follower then self.followState = nil return end self.followState = { leader = leader, follower = follower } end function World:stopFollow() self.followState = nil end -- The leader has just committed to a step out of (fromX, fromY); the follower -- takes that cell if it is standing next to it. A follower that is somewhere -- else entirely simply does not move, the way CheckObjectVisibility drops a -- pairing it cannot make sense of. function World:followStep(leader, fromX, fromY) local state = self.followState if not (state and state.leader == leader) then return end local follower = state.follower if not follower or follower.moving or not follower.scriptStep then return end local dx, dy = fromX - follower.cellX, fromY - follower.cellY local dir if dy == 0 and dx == 1 then dir = "right" elseif dy == 0 and dx == -1 then dir = "left" elseif dx == 0 and dy == 1 then dir = "down" elseif dx == 0 and dy == -1 then dir = "up" end if dir then follower:scriptStep(dir) end end -- FreezeAllObjects + the caller's `res FROZEN_F` on the moved one -- (engine/overworld/map_objects.asm FreezeAllOtherObjects, :2529-2544), plus -- UnfreezeFollowerObject (scripting.asm:758): a follower is walked by -- World:followStep and must not be held. -- -- The pool is walked rather than self.npcs because a rebuild mid-script can -- leave an object in one list and not the other, which is the same pair -- World:step's unfreeze walks. function World:freezeAllOtherNpcs(objectId) local moved = self:objectEntity(objectId) local follower = self.followState and self.followState.follower local function hold(npc) if npc and npc ~= moved and npc ~= follower and npc ~= self.player then npc.frozen = true end end for _, npc in pairs(self.npcPool or {}) do hold(npc) end for _, npc in ipairs(self.npcs or {}) do hold(npc) end end function World:beginMovement(objectId, bytes, onDone) -- ApplyMovement's FIRST act, before it has even read the movement pointer: -- `ld a, c / farcall FreezeAllOtherObjects` (engine/overworld/scripting.asm -- :751-755). FreezeAllObjects sets FROZEN_F on every struct and the caller -- then clears it on the one being moved (map_objects.asm:2529-2544), so from -- the first applymovement of a script until EndScript's UnfreezeAllObjects -- NOBODY on the map moves under their own movement function. -- -- The port used to freeze only the object a command actually touched -- (NPC:scriptStep / :scriptFace set `frozen` themselves), so every OTHER -- wanderer and spinner kept going for the whole exchange: a beaten spinner -- carried on rolling new facings through his own after-battle text, and the -- Rocket hideout's spin trainers -- the ones whose facing is the puzzle -- -- turned under every text box in the room. SeenByTrainerScript's -- `applymovementlasttalked` (engine/events/trainer_scripts.asm:14) is the -- freeze that covers the seen text, the battle and the talk-after. -- -- `frozeNpcs` is the release latch: World:step drops it the frame the whole -- interaction settles, which is where the cart runs UnfreezeAllObjects. self:freezeAllOtherNpcs(objectId) self.frozeNpcs = true self.moveState = { objectId = objectId, bytes = bytes or {}, i = 1, sleep = 0, onDone = onDone, } end function World:updateMovement() local st = self.moveState if not st then return end local ent = self:objectEntity(st.objectId) if not ent then local cb = st.onDone self.moveState = nil if cb then cb() end return end if st.sleep and st.sleep > 0 then st.sleep = st.sleep - 1 return end if ent.moving then return end -- StepFunction_NPCJump's `.Land` beat (engine/overworld/map_objects.asm:1150): -- `.Jump` already walked one cell and ran GetNextTile again, so the second -- cell belongs to the jump and not to the next movement byte. if st.pendingStep then local dir = st.pendingStep st.pendingStep = nil local fromX, fromY = ent.cellX, ent.cellY if ent:scriptStep(dir) then self:followStep(ent, fromX, fromY) end return end while st.i <= #st.bytes do local b = st.bytes[st.i] st.i = st.i + 1 -- engine/overworld/movement.asm:163 if b == 0x57 then local duration = st.bytes[st.i] or 0 st.i = st.i + 1 if ent.scriptRockSmash then ent:scriptRockSmash(duration) end st.sleep = duration return end local act = Movement.decodeByte(b) if act.kind == "end" then -- SLIDING_F is an object flag, not a stream one, so a stream that never -- ran remove_sliding would otherwise leave the object stuck holding its -- facing and step frame forever. ent.sliding = nil local cb = st.onDone self.moveState = nil if cb then cb() end return elseif act.kind == "turn" then ent:scriptFace(act.dir) elseif act.kind == "step" then local fromX, fromY = ent.cellX, ent.cellY if ent:scriptStep(act.dir) then self:followStep(ent, fromX, fromY) end return elseif act.kind == "jump" then -- JumpStep (engine/overworld/movement.asm:741) hands the object to -- StepFunction_NPCJump, which runs TWO beats and advances OBJECT_MAP_X/Y -- on each, so a jump crosses two cells. No collision test: InitStep and -- GetNextTile only record the tile for the grass flag and never block -- scripted movement. local fromX, fromY = ent.cellX, ent.cellY if ent:scriptStep(act.dir) then st.pendingStep = act.dir self:followStep(ent, fromX, fromY) end return elseif act.kind == "sleep" then st.sleep = act.frames or 0 return elseif act.kind == "teleport" then -- teleport_from / teleport_to hand the object to their own step type for -- a fixed number of frames (NPC:scriptTeleport); the movement stream -- waits them out on the same counter step_sleep uses. if ent.scriptTeleport then ent:scriptTeleport(act.mode, act.frames) st.sleep = act.frames or 0 end return elseif act.kind == "sliding" then -- Movement_set_sliding / _remove_sliding (engine/overworld/movement.asm -- :353-363) toggle SLIDING_F; SetFacingStepAction and SetFacingBumpAction -- both bail to SetFacingCurrent while it is set -- (engine/overworld/map_object_action.asm:48, :74), so the object holds -- its facing AND its step frame for the whole stream. Both handlers end -- in `jp ContinueReadingMovement`, so no frame is consumed and the next -- byte is read in this same pass: fall through the while loop. ent.sliding = act.on elseif act.kind == "fixfacing" then -- FIXED_FACING_F: InitStep jumps past the OBJECT_DIRECTION write -- (engine/overworld/map_objects.asm:284-294), so the object steps -- without turning. ContinueReadingMovement again, so no frame here. ent.fixedFacing = act.fixed elseif act.kind == "treeshake" then -- Movement_tree_shake: 24 frames of STEP_TYPE_SLEEP with OBJECT_ACTION -- set to OBJECT_ACTION_WEIRD_TREE (engine/overworld/movement.asm:334). if ent.scriptTreeShake then ent:scriptTreeShake(act.frames) st.sleep = act.frames or 0 end return end end -- A stream that walked off the end of its bytes without an `end` byte still -- has to drop SLIDING_F: the flag lives on the object, and leaving it set -- freezes that NPC's facing and step frame for the rest of the map. ent.sliding = nil local cb = st.onDone self.moveState = nil if cb then cb() end end function World:askYesNo(onChoose) local game = self.game if not (game and game.stack) then if onChoose then onChoose(true) end return end self.choicebox = true -- The box World:showText left standing because this very command was the next -- one in the list. Handing it the shared TextBox's own `choice` hook is what -- puts the prompt over it: the stay latch has to come off first, since a -- staying box short-circuits before the choice branch (src/render/TextBox.lua -- :238). Nothing is re-printed, so the page keeps the exact two lines it -- typed -- including a `cont`'s scrolled pair. local held = self.stayedTextBox if held then self.stayedTextBox = nil held.stay = nil held.choice = function(yes) self.textbox = nil self.choicebox = nil if onChoose then onChoose(yes) end end return end -- `yesorno` on the cart puts the YES/NO box ABOVE the text box that is -- still holding the question (InitYesNoTextBoxParameters); the port used to -- push a bare choice box, so the question vanished the moment the answer -- appeared. The last page is re-shown instantly underneath and the shared -- TextBox's own `choice` hook stacks the prompt on it, which is the same -- pairing every Gen 1 prompt already uses. local question = self.lastText if question then game.stack:push(TextBox.new(game, question, nil, { instant = true, choice = function(yes) self.choicebox = nil if onChoose then onChoose(yes) end end, })) return end game.stack:push(ChoiceBox.new(game, function(yes) self.choicebox = nil if onChoose then onChoose(yes) end end)) end -- engine/events/pokepic.asm:44-48 PokepicMenuHeader `menu_coords 6, 4, 14, 13`, -- and PadFrontpic (engine/gfx/load_pics.asm:342) fitting 5x5/6x6 into the 7x7. local POKEPIC = { left = 6, top = 4, w = 9, h = 10, pad = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } }, } function World:showPokePic(speciesIndex) local id, def = speciesByIndex( self.game and self.game.data and self.game.data.pokemon, speciesIndex) local path = def and def.spriteFront if not path then self.pokePic = nil return end local ok, img = pcall(Assets.image, path) self.pokePic = ok and img or nil self.pokePicName = id -- _CGB_Pokepic (engine/gfx/cgb_layouts.asm:744) fills the whole menu box with -- PAL_BG_GRAY, so the window is the map's grey ramp, not the mon's colors. local set = self.palettes and self.map and self.map.def and Palettes.bgSet(self.palettes, self.map.def, self.daytime or "DAY") self.pokePicColors = set and set[1] or nil end -- WaitButton (home/text.asm), which Script_waitbutton farcalls: hold the frame -- until the player presses A or B. Only the `waitbutton` under an open -- `pokepic` window reaches here -- every other one follows a `writetext` whose -- last page has already taken the press (src/script/gen2/Vm.lua) -- and it is -- what puts the starter's pic on screen long enough to look at before the -- yes/no (#911). -- -- `fresh` skips the tick the wait was armed on. Game2's fixed step reads -- `wasPressed("a")` for World:interact ABOVE World:step, so the press that -- opened the ball script is still this tick's edge when the poll first runs; -- without the skip that single press would both raise the pic and dismiss it. function World:waitForButton(done) self.waitButton = { done = done, fresh = true } end function World:pollWaitButton() local wb = self.waitButton if not wb then return end if wb.fresh then wb.fresh = false return end local input = self.game and self.game.input -- A headless build with no pad cannot answer, so it answers at once rather -- than parking the script forever. if input and not (input:wasPressed("a") or input:wasPressed("b")) then return end self.waitButton = nil if wb.done then wb.done() end end -- A step rolls for a wild encounter, using the map's own rate and the slot -- list for the current time of day -- which is why the same patch of Route 29 -- gives Pidgey at 8am and Hoothoot at 8pm. -- Returns true when a battle started, so the caller stops the step. -- -- WHETHER a step may roll at all is CanEncounterWildMon -- (engine/overworld/events.asm), and the port used to get that wrong in a way -- that emptied whole dungeons: it asked only "is this tall grass or water", -- while the cart SKIPS the grass array entirely on a CAVE or DUNGEON map and -- lets any non-ice walkable tile roll. Dark Cave and Union Cave gave zero -- encounters because of it. FieldMoves.canEncounterWildMon is the whole -- routine, ice check and wildoff flag included. -- -- WHICH list is rolled is a separate question with a separate answer: -- ChooseWildEncounter (engine/overworld/wildmons.asm) reads CheckOnWater, the -- PERMISSION of the tile underfoot. So a surfing step in a cave rolls the -- water list and a walking step on the same map rolls the grass one. -- The wild pick, wrapped in encounter.roll (return nil to suppress, a table -- without calling next_ to force) and then encounter.species (which transforms -- a non-nil roll before the Unown and repel filters). Same two names, same -- order and same { species, level } shape as Gen 1. -- -- ctx keeps Gen 1's mapId / terrain / rng and adds what a Gen 2 roll genuinely -- depends on and Gen 1 has no equivalent of: `daytime` (grass slots are per -- time of day), `environment` (a CAVE or DUNGEON encounters on every walkable -- tile), `kind` -- which of Gold's roll paths this is: "wild", "contest" or -- "script" -- and `tables`, the swarm-substituted lists the engine really -- rolled from, so a mod sees the same table _SwarmWildmonCheck picked. -- -- Not the per-step hot path Gen 1 guards: every caller is already past the -- encounter-rate gate, so ctx is built once per encounter that actually -- triggers and the wants check only skips the two chains. function World:rollEncounter(kind, terrain, tables, vanilla) local map = self.map local ctx = { mapId = map and map.id, terrain = terrain, -- Same guard World:rockRandom uses: a headless suite has no love global. rng = (love and love.math and love.math.random) or math.random, kind = kind, daytime = self.daytime, environment = map and map.def and map.def.environment, tables = tables, data = self.game and self.game.data, } if not (Runtime.wantsHook("encounter.roll") or Runtime.wantsHook("encounter.species")) then return vanilla(tables, ctx) end local enc = Runtime.call("encounter.roll", vanilla, tables, ctx) if enc then enc = Runtime.call("encounter.species", sameEncounter, enc, ctx) end return enc end -- ApplyMusicEffectOnEncounterRate (engine/overworld/wildmons.asm:233-248): -- POKEMON MARCH and the RUINS OF ALPH station double the rate, POKEMON LULLABY -- halves it. It reads wMapMusic, so a radio station left playing counts. local MUSIC_RATE = { Music_PokemonMarch = "double", Music_RuinsOfAlphRadio = "double", Music_PokemonLullaby = "half", } function World.musicEncounterRate(rate, song) local effect = rate and song and MUSIC_RATE[song] if not effect then return rate end -- `sla b` / `srl b`, so the double wraps at a byte like the cart's does. if effect == "half" then return math.floor(rate / 2) end return (rate * 2) % 256 end function World:tryWildEncounter() local game, player, map = self.game, self.player, self.map if not (game and player and map and self.encounters) then return false end if self:wildCooldownStep() then return false end local save = game.save if not (save and save.party and #save.party > 0) then return false end local collision = map:cellCollision(player.cellX, player.cellY) local environment = map.def and map.def.environment if not FieldMoves.canEncounterWildMon( environment, collision, self.noWildEncounters) then return false end -- RandomEncounter's `bit STATUSFLAGS2_BUG_CONTEST_TIMER_F` gate sits between -- CanEncounterWildMon and TryWildEncounter, and it takes a WHOLE different -- path: the park's own table, its own two encounter rates, and no roamer -- check at all -- ChooseWildEncounter_BugContest never calls -- CheckEncounterRoamMon, which is why no beast can turn up in the contest. if BugContest.isActive(save) then return self:tryContestEncounter(collision) end -- TryWildEncounter's own order: `.EncounterRate` FIRST, and -- ChooseWildEncounter -- roamer check included -- only on a pass. The port -- rolled for a beast above the rate gate, which on a 10 percent route made -- one turn up about ten times as often per grass step as the cart does. local tables = self:wildTables() local onWater = FieldMoves.encounterTable(collision) == "water" local rate if onWater then rate = Encounter.waterRate(tables, map.id) else rate = Encounter.grassRate(tables, map.id, self.daytime) end -- ApplyMusicEffectOnEncounterRate runs first (wildmons.asm:213-215). rate = World.musicEncounterRate(rate, Music.mapSong()) -- ApplyCleanseTagEffectOnEncounterRate (engine/overworld/wildmons.asm:250-267): -- one `srl b`, however many mons are holding one. for _, mon in ipairs(save.party or {}) do if rate and mon.item == "CLEANSE_TAG" then rate = math.floor(rate / 2) break end end if not Encounter.triggers(rate, nil) then return false end -- CheckEncounterRoamMon is the FIRST thing ChooseWildEncounter does, before -- it reaches for the map's own slot list -- so a beast REPLACES the map's -- encounter rather than adding to it, and only on a map that has a table at -- all (LoadWildMonDataPointer has already answered, which is what the -- `self.encounters` guard above is). Roamers.checkEncounter refuses while -- surfing, which is what keeps Suicune out of the water. local met = Roamers.checkEncounter(save, map.id, onWater, self:roamRandom()) if met then -- CheckEncounterRoamMon writes wCurPartyLevel like any other pick, so the -- repel filter below applies to a beast too: a level 41 lead repels it. if self:repelSuppresses(met.level) then return false end local beast = Roamers.beginBattle(save, met.index, game.data) if beast then save.pokedex = save.pokedex or { seen = {}, caught = {} } save.pokedex.seen[beast.species] = true self:startBattle({ wild = beast, roaming = met.index }) return true end end local roll = self:rollEncounter("wild", onWater and "water" or "grass", tables, onWater and rollWaterVanilla or rollGrassVanilla) if not roll then return false end -- ChooseWildEncounter's Unown arm (engine/overworld/wildmons.asm): an UNOWN -- slot on a map whose puzzles are all unsolved is NO ENCOUNTER, not a -- different one -- `ld a, [wUnlockedUnowns] / and a / jr z, .nowildbattle`. -- That is what keeps the Ruins chambers empty until a wall has been solved. local monOpts = nil if roll.species == Unown.SPECIES then local flags = self:engineFlags() if not Unown.anyUnlocked(flags) then return false end -- LoadEnemyMon's .GenerateDVs loop rerolls until CheckUnownLetter clears -- the form, so a chamber only ever produces letters its own puzzle -- unlocked. monOpts = { dvs = Unown.wildDVs(flags, Mon.randomDVs) } end -- CheckRepelEffect, the last gate TryWildEncounter runs, and it runs AFTER -- the mon has been chosen -- which is why a repel filters on the level that -- was rolled rather than on anything about the map. if self:repelSuppresses(roll.level) then return false end local wild = Mon.new(game.data, roll.species, roll.level, monOpts) if not wild then return false end save.pokedex = save.pokedex or { seen = {}, caught = {} } save.pokedex.seen[roll.species] = true self:startBattle({ wild = wild }) return true end -- CheckRepelEffect (engine/overworld/wildmons.asm). With wRepelEffect -- (save.repelSteps) still ticking, the chosen encounter is dropped when its -- level is BELOW the first party member that is not fainted: -- `ld a, [wCurPartyLevel] / cp [hl] / jr nc, .encounter` takes the encounter -- on greater-or-equal, so a lead at the wilds' own level repels nothing. -- -- The walk starts at wPartyMon1HP and skips every slot whose two HP bytes are -- zero, so an EGG (DayCare_GiveEgg zeroes its HP) is never the mon a repel -- measures against. The counter is StepEvents.repelStep's; nothing here -- touches it. function World:repelSuppresses(level) local save = self.game and self.game.save if not save then return false end if (save.repelSteps or 0) <= 0 then return false end if not level then return false end local lead for _, mon in ipairs(save.party or {}) do if (mon.hp or 0) > 0 then lead = mon break end end if not (lead and lead.level) then return false end return level < lead.level end -- CheckWildEncounterCooldown (engine/overworld/events.asm:357-365), the first -- thing RandomEncounter runs (events.asm:1122): zero is a free step, otherwise -- the counter ticks and only the step that lands on zero may roll. function World:wildCooldownStep() local left = self.wildCooldown or 0 if left <= 0 then return false end left = left - 1 self.wildCooldown = left return left > 0 end -- LoadWildMonDataPointer's first move, for the grass list and the water one -- alike: _SwarmWildmonCheck searches SwarmGrassWildMons / SwarmWaterWildMons -- ahead of the Johto and Kanto tables, and only while the player is standing -- on the swarm's own map. Roamers.Swarm.tables hands the ORIGINAL table back -- when no swarm applies, so an ordinary step pays nothing for this. function World:wildTables() local save = self.game and self.game.save if not (save and self.map and self.encounters) then return self.encounters end return Roamers.Swarm.tables(save, self.encounters, self.map.id) end -- --------------------------------------------------------------------------- -- The Bug Catching Contest (engine/events/bug_contest/). -- --------------------------------------------------------------------------- -- -- src/core/gen2/BugContest.lua is the whole model -- the park's encounter -- table, the twenty minute clock, the park balls, the one held catch, the -- scoring and the podium -- and every rule below is one call into it. What -- lives here is the four CALL SITES the cart has and the port had none of: -- -- RandomEncounter's contest branch World:tryContestEncounter -- CheckTimeEvents' CheckBugContestTimer World:checkTimeEvents -- BugCatchingContestBattleScript's tail World:bugContestBattleOver -- BugCatchingContestOverScript World:bugContestOver -- -- The gate conversation itself is extracted script bytecode and needs nothing -- here: Route35NationalParkGate's officer runs through the same VM every other -- NPC does, and its six `special`s are handled in src/script/gen2/Specials.lua. -- _TryWildEncounter_BugContest: the rate is the tile's (40 percent in the -- park's long grass, 20 in the ordinary kind), the row comes from ContestMons -- and the level from that row's span. The mon is built through -- src/battle/gen2/Mon.lua like any other wild encounter, so it arrives with a -- Gen 2 moveset and DVs the judge can score. function World:tryContestEncounter(collision) local game, save = self.game, self.game and self.game.save if not (game and save) then return false end if not BugContest.triggers(Permissions.isSuperTallGrass(collision)) then return false end local roll = self:rollEncounter("contest", "grass", nil, rollContestVanilla) if not roll then return false end local wild = Mon.new(game.data, roll.species, roll.level) if not wild then return false end save.pokedex = save.pokedex or { seen = {}, caught = {} } save.pokedex.seen[roll.species] = true -- BATTLETYPE_CONTEST, which is what puts PARKBALL in the battle menu and -- sends a caught mon to wContestMon instead of to the party. self:startBattle({ wild = wild, contest = true }) return true end -- CheckTimeEvents (engine/overworld/events.asm), the clock half of the -- player-event chain. While ENGINE_BUG_CONTEST_TIMER is set it polls -- CheckBugContestTimer and NOTHING else -- the daily reset, the swarm and the -- phone call are all on the `.do_daily` arm the contest skips. A carry out of -- it is BugCatchingContestOverScript. -- -- `.do_daily` is CheckDailyResetTimer, CheckSwarmFlag, CheckPokerusTick and -- CheckPhoneCall here, in the cart's order. The reset is what clears -- wDailyFlags1, so it is what makes the -- contest a ONCE A DAY thing -- BugContestResults_CleanUp's `setflag -- ENGINE_DAILY_BUG_CONTEST` is the bit the officer refuses on, and nothing -- else ever takes it back down. The Pokerus tick counts down every infected -- party slot by the days since it last ran. CheckPhoneCall is the random -- incoming ring: the whole five-test gate (the entrance tile underfoot, the -- 20/10/5/3 minute countdown, the coin flip, the signal, an available -- caller) is src/core/gen2/Phone.lua's tryRandomCall, and its carry is -- Script_ReceivePhoneCall, so a landed call answers true the same way the -- contest's over-script does. What belongs here is only what the gate reads -- off the world: CheckStandingOnEntrance (home/map_objects.asm) is COLL_DOOR -- / COLL_DOOR_79 / COLL_STAIRCASE / COLL_CAVE under the player's feet. function World:checkTimeEvents() local save = self.game and self.game.save if not save then return false end if not BugContest.isActive(save) then Apricorns.checkDailyResetTimer(save) -- CheckSwarmFlag, second on `.do_daily` and the ONLY thing that ever ends -- a swarm: the reset above takes DAILYFLAGS1_SWARM down, and this is what -- notices and clears wSwarmMapGroup/Number and wFishingSwarmFlag with it. -- Without it a Dunsparce call would leave Dark Cave swarming forever. Roamers.Swarm.check(save) Pokerus.checkTick(save) local ctx = self:stepContext().phone local coll = self.map and self.player and self.map:cellCollision(self.player.cellX, self.player.cellY) ctx.standingOnEntrance = coll == 0x71 or coll == 0x79 or coll == 0x7a or coll == 0x7b local call = require("src.core.gen2.Phone").checkPhoneCall(save, ctx) if call then return self:receivePhoneCall(call) end return false end if not BugContest.tickTimer(save) then return false end return self:bugContestOver("time") end -- BugCatchingContestOverScript and BugCatchingContestOutOfBallsScript: the same -- two commands over a different line, and both fall into -- BugCatchingContestReturnToGateScript. Neither is a std script (they sit in -- engine/events/bug_contest/contest.asm, which nothing points at), so the text -- is authored here the way World:repelWoreOff's is. local BUG_CONTEST_TIME_UP = Strings.source("ANNOUNCER: BEEEP!\fTime's up!") local BUG_CONTEST_IS_OVER = Strings.source("ANNOUNCER: The\nContest is over!") local SFX_ELEVATOR_END = 39 function World:bugContestOver(reason) if self.bugContestEnding then return false end self.bugContestEnding = true self:playSfxNamed("Sfx_ElevatorEnd", SFX_ELEVATOR_END) local line = (reason == "balls") and BUG_CONTEST_IS_OVER or BUG_CONTEST_TIME_UP self:showText(Strings(line), function() self.bugContestEnding = nil self:bugContestResults() end) return true end -- `jumpstd BugContestResultsWarpScript`, run the way the cart runs it: the std -- script itself does the warp to the north gate, the walk in, the judging, the -- prize and the party hand-back, so this hands control back to the VM rather -- than reimplementing any of it. A cache without that std script leaves the -- contest state alone -- stopping the clock here would strand the player in the -- park with no way to be judged. function World:bugContestResults() local entry = self.stdScripts and self.stdScripts.scripts and self.stdScripts.scripts.BugContestResultsWarpScript local key = entry and entry.key if not (key and self.vm) then return false end return self.vm:start(key) end -- BugCatchingContestBattleScript's tail: `readmem wParkBallsRemaining / -- iffalse BugCatchingContestOutOfBallsScript`, checked on the way out of every -- contest battle. CheckContestBattleOver has already turned the last ball into -- a DRAW, so the battle is over either way by the time this runs. function World:bugContestBattleOver() local save = self.game and self.game.save if not save then return false end if not BugContest.isActive(save) then return false end if not BugContest.isOver(save) then return false end return self:bugContestOver("balls") end -- True when `itemId` is one of the three rods. The item table is consulted -- when the caller has one, so a cache whose ItemNames sit at other indices (a -- different Gen 2 ROM) refuses rather than fishing with a BICYCLE. function World.isRod(itemId, items) local index = ROD_INDEX[itemId] if not index then return false end local def = items and items[itemId] if def and def.index and def.index ~= index then return false end return true end -- FishFunction's .TryFish (engine/events/overworld.asm): the roll on its own, -- with no animation and no battle. `rod` is the item id; the group comes from -- the map's own MAP_FISHGROUP and the rod picks which of that group's three -- lists is rolled. Returns the jumptable case the cart lands on, plus the -- hooked mon on "battle": -- -- "nowhere" $3 .FailFish surfing, or not facing water -- "nofish" $4 .FishNoFish facing water the map has no group for -- "nibble" $1 .FishNoBite the group's own roll came up empty -- "battle" $2 .FishGotSomething -- -- Split out of World:tryFishing because Script_GotABite writes RodBiteText -- BEFORE its startbattle, so the cast has to run between the roll and the -- battle rather than after it. function World:rollFishing(rod) local game, player, map = self.game, self.player, self.map if not (game and player and map and self.encounters) then return "nowhere" end -- .TryFish reads wPlayerState first and drops straight to $3 .FailFish while -- the player is PLAYER_SURF / PLAYER_SURF_PIKA: you cannot fish from the -- back of a Lapras, only from the bank. if FieldMoves.isSurfing(self.playerState) then return "nowhere" end -- The cart requires the tile the player is FACING to be water, not the one -- they stand on. local d = Map.DELTA[player.facing or "down"] or Map.DELTA.down local cx, cy = player.cellX + d[1], player.cellY + d[2] if not Permissions.isWater(map:cellCollision(cx, cy)) then return "nowhere" end -- GetFishingGroup (home/map.asm) is MAP_FISHGROUP off the map header, and -- .facingwater's `and a / jr nz` sends FISHGROUP_NONE to .FishNoFish. The -- Encounter helper defaults an unknown map to the pond, so the header is -- read here rather than left to it. local def = self.maps and self.maps[map.id] local group = def and def.fishGroup if not group or group == 0 or group == "FISHGROUP_NONE" then return "nofish" end -- GetFishGroupIndex reads wFishingSwarmFlag between the header and the -- FishGroups index, which is how the Route 32 Qwilfish and Route 44 Remoraid -- swarms reach the rods at all: the phone call's ActivateFishingSwarm writes -- the flag and nothing about the map changes. Roamers.Swarm.fishing is the -- same store CheckSwarmFlag clears when the swarm expires. local swarm = Roamers.Swarm.fishing(game.save) local roll if Runtime.wantsHook("encounter.fishing") then -- Gen 1's three arguments, in Gen 1's order: the rod, the map, and the -- candidate list the chain may inspect or replace before the roll. Gold's -- candidates are the FishGroups row the map header (plus any swarm swap) -- resolves to, so the third argument is that row rather than Gen 1's -- extracted fishing group -- same role, same position. ctx is the fourth -- argument and is Gen 2's alone; nothing before it moved. local group = Encounter.fishGroupFor(self.encounters, (def and def.fishGroup) or "FISHGROUP_POND", swarm) local groups = self.encounters and self.encounters.fishGroups roll = Runtime.call("encounter.fishing", fishVanilla, rod, map.id, groups and groups[group], { fishGroup = group, swarm = swarm, encounters = self.encounters, maps = self.maps, data = game.data }) else roll = Encounter.fishSlot(self.encounters, map.id, rod, nil, self.maps, swarm) end if not roll or not roll.species then return "nibble" end local wild = Mon.new(game.data, roll.species, roll.level) if not wild then return "nibble" end local save = game.save if save then save.pokedex = save.pokedex or { seen = {}, caught = {} } save.pokedex.seen[roll.species] = true end return "battle", wild end -- Roll and start the battle in one call, for a caller that wants the outcome -- without the cast animation. The PACK goes through World:useRod instead. -- -- The return widened when the input path landed: it used to be -- "battle"/"nibble"/nil, and nil could not tell the two failures apart -- the -- PACK has to keep itself open for one ("nowhere") and quit for the other -- ("nofish"), which is the whole difference between UseItem's .Oak and its -- PACKSTATE_QUITRUNSCRIPT. function World:tryFishing(rod) local outcome, wild = self:rollFishing(rod) if outcome == "battle" and wild then self:startBattle({ wild = wild }) end return outcome end -- UseRod (engine/items/item_effects.asm), which is a bare `farcall -- FishFunction` after OldRodEffect / GoodRodEffect / SuperRodEffect have -- chosen rod 0/1/2. Rolls first, then hands the outcome to the cast. -- -- A rod is ITEMMENU_NOUSE in battle, and a running script owns the world, so -- both answer "nowhere" -- which is the cart's own "This isn't the time to use -- that!", the case where the PACK stays open. function World:useRod(rodId) if self.battleActive or self:busy() then return "nowhere" end local outcome, wild = self:rollFishing(rodId) if outcome == "nowhere" then return "nowhere" end self:beginFishing(outcome, wild) return outcome end -- The field half of engine/items/pack.asm UseItem: an item used from the PACK -- in the overworld. Returns nil for anything this world does not handle, so -- the PACK falls through to its own onChoose (TM teaching lives there); -- otherwise the FishFunction outcome, where "nowhere" means the PACK must stay -- open and print OakThisIsntTheTimeText. function World:useFieldItem(itemId) local items = self.game and self.game.data and self.game.data.items if itemId == "ITEMFINDER" then return self:useItemfinder() end if itemId == "BICYCLE" then return self:useBike(itemId) end if itemId == "SACRED_ASH" then return self:useSacredAsh() end if itemId == "ESCAPE_ROPE" then return self:useEscapeRope(itemId) end if itemId == "SQUIRTBOTTLE" then return self:useSquirtbottle() end if REPEL_STEPS[itemId] then return self:useRepel(itemId) end if TROPHY_BOXES[itemId] then return self:openTrophyBox(itemId) end if not World.isRod(itemId, items) then return nil end return self:useRod(itemId) end -- EscapeRopeOrDig's .CheckCanDig (engine/events/overworld.asm): the map's -- environment must be CAVE or DUNGEON and the banked warp triple must name a -- real warp. The cart reads wDigWarpNumber / wDigMapGroup / wDigMapNumber -- there; this port keeps one banked triple -- backupWarp, the same store a -1 -- warp destination resolves through and the one the save carries -- so the -- rope pays out to the last warp that banked it rather than to a second -- register. A triple that names a map or warp the cache does not carry is -- the cart's zeroed-triple `.fail` arm. function World:escapeRopeTarget() local env = self.map and self.map.def and self.map.def.environment if env ~= "CAVE" and env ~= "DUNGEON" then return nil end local backup = self.backupWarp local dest = backup and backup.map and self.maps and self.maps[backup.map] local destWarp = dest and dest.warps and backup.warp and dest.warps[backup.warp] if not destWarp then return nil end return backup.map, destWarp end -- The shared tail of .UsedEscapeRopeScript / .UsedDigScript: SFX_WARP_TO, -- `loadvar VAR_MOVEMENT, PLAYER_NORMAL`, then `newloadmap MAPSETUP_DOOR` with -- the triple already in wNextWarp -- EnterMapWarp and GetWarpDestCoords land -- the player on the destination warp's own tile. The dig-spin sprite work is -- not ported, the same standing decision World:flyTo records for the two fly -- animations. function World:runEscapeWarp(destMapId, destWarp) self:playSfxNamed("Sfx_WarpTo", SFX_WARP_TO) self:applyPlayerState(FieldMoves.PLAYER_NORMAL) return self:runMapSetup(MAPSETUP_DOOR, function() local ok = self:setMap(destMapId, destWarp.x, destWarp.y, "down") if ok then self:spawnFacing() end return ok end) end -- EscapeRopeEffect (engine/items/item_effects.asm): EscapeRopeFunction, and -- UseDisposableItem only when it succeeded -- a refusal costs nothing. The -- ESCAPE_ROPE is ITEMMENU_CLOSE, so "nowhere" sends UseItem's .Field arm to -- .Oak (the PACK prints OakThisIsntTheTimeText and stays open) and a success -- quits the PACK, with the queued script -- the used-rope line and the warp -- -- running once the overworld owns the frame, exactly the QueueScript -- placement the cart gives .UsedEscapeRopeScript. function World:useEscapeRope(itemId) if self.battleActive or self:busy() then return "nowhere" end local destMapId, destWarp = self:escapeRopeTarget() if not destMapId then return "nowhere" end 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) self.queuedFieldMove = { ok = true, action = "escaperope", destMap = destMapId, destWarp = destWarp, text = FieldMoves.TEXT.USE_ESCAPE_ROPE, } return "escape_rope" end -- _Squirtbottle (engine/events/squirtbottle.asm): the script is QUEUED and -- wItemEffectSucceeded is set to 1 unconditionally, so the PACK always quits; -- .CheckCanUseSquirtbottle then picks between WateredWeirdTreeScript and the -- "nothing happened" line over the overworld. The check is run here, at -- queue time, because the player cannot turn between the press and the drain. -- -- The watered-tree body is not invented: it is sliced out of the extracted -- Sudowoodo talk script (maps/Route36.asm SudowoodoScript), whose .Fight arm -- falls through into the exported WateredWeirdTreeScript right after its -- yesorno's `iffalse` + `closetext` pair -- so the PACK use and the talk path -- run the very same decoded rows, battle and TWIN swap included. local SPRITEMOVEDATA_SUDOWOODO = 0x17 local TEXT_SQUIRTBOTTLE_NOTHING = Strings.source( "{PLAYER} sprinkled\nwater.\fBut nothing\nhappened…") function World:squirtbottleTreeScript() local p, map = self.player, self.map if not (p and map and map.id == "ROUTE_36") then return nil end local d = Map.DELTA[p.facing or "down"] or Map.DELTA.down local npc = self:npcAt(p.cellX + d[1], p.cellY + d[2]) local def = npc and npc.def if not (def and def.movement == SPRITEMOVEDATA_SUDOWOODO and def.scriptKey) then return nil end local talk = self.scripts and self.scripts[def.scriptKey] local armKey for _, cmd in ipairs(talk or {}) do if cmd.op == "iftrue" and cmd.script then armKey = cmd.script break end end local arm = armKey and self.scripts and self.scripts[armKey] if not arm then return nil end for i, cmd in ipairs(arm) do if cmd.op == "iffalse" then -- WateredWeirdTreeScript starts past the yesorno's own closetext. local start = i + 1 if arm[start] and arm[start].op == "closetext" then start = start + 1 end if not arm[start] then return nil end local rows = {} for j = start, #arm do rows[#rows + 1] = arm[j] end return rows end end return nil end function World:useSquirtbottle() if self.battleActive or self:busy() then return "nowhere" end local tree = self:squirtbottleTreeScript() if tree then self.queuedScript = tree else self.queuedScript = { { op = "opentext" }, { op = "rawtext", text = TEXT_SQUIRTBOTTLE_NOTHING }, { op = "waitbutton" }, { op = "closetext" }, { op = "end" }, } end return "squirtbottle" end -- CheckRegisteredItem (engine/overworld/select_menu.asm), re-run on every -- SELECT press rather than only when the item was registered: tossing the -- last copy, trading it away or a TM losing CANT_SELECT_F retroactively (it -- never does, but the pack does run out) all answer the same -- ".NoRegisteredItem" way the cart does -- silently clearing the slot instead -- of holding a stale pointer. wWhichRegisteredItem packs a pocket and a -- quantity so a KEY_ITEM's count never underflows and an ITEM/BALL's does; -- this port keeps only the item id and re-reads the live inventory count, -- which answers the same "not enough left" case for zero without needing the -- packed field at all. function World:registeredItemId() local save = self.game and self.game.save local reg = save and save.registeredItem local id = reg and reg.id if not id then return nil end local items = self.game and self.game.data and self.game.data.items local def = items and items[id] local count = save.inventory and save.inventory[id] if not def or def.canSelect == false or not count or count <= 0 then save.registeredItem = nil return nil end return id end -- RegisterItem (engine/items/pack.asm): CheckSelectableItem gates it, the -- same ITEMATTR_PERMISSIONS bit `registeredItemId` re-checks on use, so a -- TM/HM or anything else with CANT_SELECT_F set refuses. The cart reaches -- this from the PACK's own USE/GIVE/TOSS/SEL/QUIT row submenu, which this -- port has not built; PackMenu instead calls it straight off the highlighted -- row on a SELECT press, the one PACK button this port left unbound. function World:registerItem(itemId) local items = self.game and self.game.data and self.game.data.items local def = items and items[itemId] local save = self.game and self.game.save if not save or not def or def.canSelect == false then return false end save.registeredItem = { id = itemId } return true end -- SelectMenu (engine/overworld/select_menu.asm): the SELECT press in the -- overworld. UseRegisteredItem's four ITEMMENU_* arms (.CantUse / .Current / -- .Party / .Overworld) are exactly the switch `useFieldItem` already runs for -- the PACK's own UseItem -- a rod, the ITEMFINDER, a REPEL, a trophy box -- -- so this is that same dispatch with "no registered item" and "no field -- handler for this one yet" as the two extra outcomes CantUseItem's own two -- call sites (.NotRegistered and .CantUse) cover on the cart. function World:useSelectItem() if self.battleActive or self:busy() then return "nowhere" end local id = self:registeredItemId() if not id then return "not_registered" end -- wUsingItemWithSelect, set for exactly the length of the effect: the -- BICYCLE is the one item whose effect reads it (.CheckIfRegistered), and it -- is what makes a SELECT press get on the bike without a line of text. self.usingItemWithSelect = true local outcome = self:useFieldItem(id) self.usingItemWithSelect = nil if outcome == nil then return "cant_use" end return outcome, id end -- UseRepel (engine/items/item_effects.asm): a REPEL's ItemAttributes give it -- ITEMMENU_CURRENT, so UseItem's `.Current` arm runs DoItemEffect and returns -- with no wItemEffectSucceeded check at all -- the PACK never quits for this -- item, win or lose. wRepelEffect already set (`and a / jp nz`) prints -- RepelUsedEarlierIsStillInEffectText and leaves the counter and the bag -- alone; otherwise the new count is written and UseItemText's tail -- (UseDisposableItem) is the one place a REPEL gets removed from the bag. -- -- The caller is PackMenu, which owns both messages ("repel_used" builds its -- own from the row's item name; "repel_active" is the fixed three-line text -- the cart shows) so this stays a plain sentinel like useRod's outcomes. -- -- ItemAttributes gives a REPEL ITEMMENU_NOUSE in battle, same as a rod, so -- the in-battle PACK (BattleState:openPack, which shares this same World -- instance) has to be refused here too rather than only from the overworld. function World:useRepel(itemId) local save = self.game and self.game.save if not save then return nil end if self.battleActive or self:busy() then return "nowhere" end if (save.repelSteps or 0) > 0 then return "repel_active" end save.repelSteps = REPEL_STEPS[itemId] if save.inventory then local left = (save.inventory[itemId] or 1) - 1 save.inventory[itemId] = left > 0 and left or nil end return "repel_used" end -- NormalBoxEffect / GorgeousBoxEffect (engine/items/item_effects.asm), which -- are the same three lines with a different DECOFLAG: SetSpecificDecorationFlag, -- _SentTrophyHomeText, UseDisposableItem. Both items are ITEMMENU_CURRENT, so -- the PACK prints and stays open rather than quitting to the field. -- -- This is the one path in Gold that can hand the player a decoration and that -- this port can actually reach; the other two (Mom's doll purchases, which -- need her savings account, and Mystery Gift, which needs a second cart) are -- not built. -- -- Both boxes are ITEMMENU_NOUSE in battle, so BattlePack answers .Oak for them -- and the flag is never granted nor the box spent mid-fight. function World:openTrophyBox(itemId) local decoFlag = TROPHY_BOXES[itemId] if not decoFlag then return nil end if self.battleActive then return "nowhere" end Decorations.giveFlag(self.events, decoFlag) local items = self.game and self.game.data and self.game.data.items local def = items and items[itemId] self:takeItem(def and def.index, 1) return "trophy_sent" end -- ItemFinder (engine/items/itemfinder.asm): CheckForHiddenItems, then one of -- two scripts is QUEUED and wItemEffectSucceeded is set unconditionally. In -- the FIELD there is no "you can't use that here" arm -- the ITEMFINDER always -- quits the PACK. The item is ITEMMENU_NOUSE in battle, where BattlePack's -- .Oak answers instead and no script is queued: quitting the PACK there would -- take the battle off the stack with it. -- -- QueueScript, not CallScript: the PACK (and the START menu under it) is still -- on the screen at this point, and the beeps and the line belong over the -- overworld. World:runQueuedScript is the other half. function World:useItemfinder() if self.battleActive then return "nowhere" end local p = self.player local found = p and HiddenItems.nearby( self.map and self.map.def, p.cellX, p.cellY, self.events) or nil self.queuedScript = HiddenItems.itemfinderScript(found, function(want, id) return self:sfxIdNamed(want, id) end) return "itemfinder" end -- ---------------------------------------------------------------- the bike -- -- src/world/gen2/Bike.lua owns every decision; this is the world state those -- decisions read and the presentation they end in. -- wBikeFlags' three bits are ENGINE_* ids like any other flag, so the map -- callbacks that set them (Route16AlwaysOnBikeCallback, -- Route17AlwaysOnBikeCallback) already land on save.engineFlags. function World:alwaysOnBike() return self:engineFlag(Bike.ENGINE_ALWAYS_ON_BIKE) end function World:downhill() return self:engineFlag(Bike.ENGINE_DOWNHILL) end -- GetPlayerTilePermission's operand: the collision under the player's feet. function World:playerCollision() local p = self.player if not (p and self.map) then return nil end return self.map:cellCollision(p.cellX, p.cellY) end -- BikeFunction (engine/events/overworld.asm), reached from BicycleEffect. -- -- The BICYCLE is ITEMMENU_CLOSE, so UseItem takes its .Field arm: a non-zero -- wFieldMoveSucceeded quits the PACK and lets the queued script run in the -- overworld, and a zero drops into .Oak instead. "nowhere" is that zero -- -- PackMenu already prints OakThisIsntTheTimeText for it -- and the three other -- answers all queue a script and let the PACK close. -- -- .GetOnBike's music is not the outdoor-song override Gen 1 uses: it silences -- the current song, plays MUSIC_BICYCLE and writes it into wMapMusic, so the -- bike theme survives until the dismount's `special PlayMapMusic` or the next -- map load puts the map's own song back. function World:useBike(itemId) if self.battleActive or self:busy() then return "nowhere" end local items = self.game and self.game.data and self.game.data.items local def = items and items[itemId or "BICYCLE"] local item = def and def.index local specialId = function(name) return self:specialIdNamed(name) end local action = Bike.tryBike({ state = self.playerState, environment = self.map and self.map.def and self.map.def.environment, collision = self:playerCollision(), alwaysOnBike = self:alwaysOnBike(), }) -- wUsingItemWithSelect, which .CheckIfRegistered reads to pick the silent -- pair of scripts. local silent = self.usingItemWithSelect and true or false if action == "mount" then self.queuedScript = Bike.mountScript(item, specialId, silent) self:playBikeMusic() return "bike_on" elseif action == "dismount" then self.queuedScript = Bike.dismountScript(item, specialId, silent) return "bike_off" elseif action == "cant_get_off" then self.queuedScript = Bike.cantGetOffScript() return "bike_stuck" end return "nowhere" end function World:playBikeMusic() local data = self.game and self.game.data local audio = data and data.audio if not (audio and audio.runtime) then return false end if not (audio.songs and audio.songs[Bike.MUSIC_BICYCLE]) then return false end Music.play(data, Bike.MUSIC_BICYCLE, true, { reason = "bike" }) return true end -- `special` names resolve through the cache's own SpecialsPointers order -- (constants.specialOrder), same as Vm:specialName but starting from the -- name instead of the decoded id -- a hand-built script has a label to write -- and no counted index to have copied down. function World:specialIdNamed(name) local order = self.constants and self.constants.specialOrder if not order or not name then return nil end for i, n in ipairs(order) do if n == name then return i - 1 end end return nil end -- SacredAshEffect / _SacredAsh (engine/items/item_effects.asm, -- engine/events/sacred_ash.asm). CheckAnyFaintedMon gates the whole effect -- on carry: an empty party or one with nothing fainted never sets -- wItemEffectSucceeded, so UseItem's .Field falls through to .Oak -- (OakThisIsntTheTimeText) with the item untouched -- PackMenu already has -- that message under "nowhere", the same answer a rod gives with no water in -- front of the player. -- -- On success SacredAshScript runs a single HealParty (revives AND fully -- heals every party member in one pass, see World:healParty) behind three -- Pokecenter-style fade cycles and the "all healed" line, then -- UseDisposableItem removes the one Ash from the bag. function World:useSacredAsh() if self.battleActive or self:busy() then return "nowhere" end local save = self.game and self.game.save local party = (save and save.party) or {} local anyFainted = false for _, mon in ipairs(party) do if not Breeding.isEgg(mon) and (mon.hp or 0) <= 0 then anyFainted = true break end end if not anyFainted then return "nowhere" end if save.inventory then local left = (save.inventory.SACRED_ASH or 1) - 1 save.inventory.SACRED_ASH = left > 0 and left or nil end local script = { { op = "special", id = self:specialIdNamed("HealParty") }, { op = "refreshmap" }, { op = "playsound", id = self:sfxIdNamed("Sfx_WarpTo", SFX_WARP_TO) }, } for _ = 1, 3 do script[#script + 1] = { op = "special", id = self:specialIdNamed("FadeOutToWhite") } script[#script + 1] = { op = "special", id = self:specialIdNamed("FadeInFromWhite") } end script[#script + 1] = { op = "waitsfx" } script[#script + 1] = { op = "opentext" } script[#script + 1] = { op = "rawtext", text = TEXT_USE_SACRED_ASH } script[#script + 1] = { op = "playsound", id = self:sfxIdNamed("Sfx_CaughtMon", 2) } script[#script + 1] = { op = "waitsfx" } script[#script + 1] = { op = "waitbutton" } script[#script + 1] = { op = "closetext" } script[#script + 1] = { op = "end" } self.queuedScript = script return "sacredash" end -- QueueScript's drain, on the same clock as the field move one below it: the -- first frame the overworld owns after the menus are gone. function World:runQueuedScript() local script = self.queuedScript if not script or self:busy() then return false end self.queuedScript = nil self.talkNpc = nil return self.vm and self.vm:start(script) or false end -- Script_FishCastRod, then Script_NotEvenANibble or Script_GotABite. Held as -- a frame counter rather than a movement byte stream because the three -- commands involved -- fish_cast_rod ($52), fish_got_bite ($51) and show_emote -- ($54) -- are object ACTION changes, not steps, and Movement.decodeByte has -- nothing to say about them. function World:beginFishing(outcome, wild) self.fishing = { phase = "cast", timer = FISH_CAST_FRAMES, outcome = outcome, wild = wild, } end function World:updateFishing() local st = self.fishing if not st then return end -- A text box owns the frame while it is up; the script only moves on when -- its own callback fires. if self.textbox or self.choicebox then return end if st.timer > 0 then st.timer = st.timer - 1 -- StepFunction_GotBite (engine/overworld/map_objects.asm:1430) is one byte -- of animation: OBJECT_SPRITE_Y_OFFSET flipped between 0 and 1 once a -- frame for the length of the bite, which is the rod jerking in the -- player's hands. The cast holds still, so only the bite bobs. if self.player then self.player.spriteYOffset = (st.phase == "bite" and st.timer % 2 == 1) and 1 or 0 end return end if self.player then self.player.spriteYOffset = 0 end if st.phase == "cast" then if st.outcome == "battle" then -- Script_GotABite: four fish_got_bite bobs with the EMOTE_SHOCK bubble -- over the player, then `pause 40` before the rod comes back. st.phase = "bite" st.timer = FISH_BITE_FRAMES self:showEmote(EMOTE_SHOCK, 0, FISH_BITE_FRAMES) return end -- Script_NotEvenANibble (queued by $1 .FishNoBite) and -- Script_NotEvenANibble2 (by $4 .FishNoFish) differ only in the -- wFishingResult they record; both write RodNothingText and fall through -- to the same PutTheRodAway. st.phase = "done" self:showText(Strings(TEXT_ROD_NOTHING), function() self.fishing = nil end) return end if st.phase == "bite" then st.phase = "done" self:showText(Strings(TEXT_ROD_BITE), function() local wild = st.wild -- PutTheRodAway and closetext come before startbattle, and the state has -- to be gone before the battle is pushed or World:busy would still be -- holding the world when it returns. self.fishing = nil -- FishFunction's `.goodtofish` writes BATTLETYPE_FISH into wBattleType -- alongside the species and level it hooked (engine/events/overworld.asm), -- which is the one condition LureBallMultiplier reads for its x3. if wild then self:startBattle({ wild = wild, battleType = "fish" }) end end) end end -- CheckHeadbuttTreeTile (home/map_objects.asm). function World.isHeadbuttTree(coll) if coll == nil then return false end return HEADBUTT_TREE[coll % 256] == true end -- CheckPartyMove (engine/events/overworld.asm): the first party mon that knows -- move `moveId`. The cart leaves that mon's slot in wCurPartyMon, which -- GetPartyNickname then reads for " did a HEADBUTT!", so the mon -- itself is returned rather than a bare yes/no. -- -- The cart also skips EGG slots; the port has no egg state yet, so there is -- nothing to skip. -- HEADBUTT's and ROCK SMASH's CheckPartyMove, and the one the route bot asks. -- Wrapped in the same fieldmove.eligibility chain FieldMoves.partyMoveUser -- offers -- two separate walks of the party, so a mod sees each site once -- -- and with the full Gen 1 ctx, because a World has the Game the love-free -- module does not. function World:partyMoveUser(moveId) local save = self.game and self.game.save local party = (save and save.party) or {} return FieldMoves.partyMoveUser(party, moveId, { save = save, data = self.game and self.game.data }) end -- TryHeadbuttOW (engine/events/overworld.asm), reached from -- TryTileCollisionEvent's .headbutt arm once the facing tile is a tree. With -- no party mon that knows HEADBUTT the whole event is skipped -- TryHeadbuttOW -- returns nc and TryTileCollisionEvent jumps to .noevent, so the A press does -- nothing at all, not even a line of text. With one, AskHeadbuttScript opens. -- -- Returns true when the event took the A press. function World:tryHeadbuttOW(cx, cy) if not (self.map and self.player) then return false end if not World.isHeadbuttTree(self.map:cellCollision(cx, cy)) then return false end local mon = self:partyMoveUser(MOVE_HEADBUTT) if not mon then return false end -- AskHeadbuttScript: opentext, writetext AskHeadbuttText, yesorno, -- iftrue HeadbuttScript. The port's yesorno re-shows the page it is -- answering underneath the prompt, which is what World:askYesNo does with -- the body World:showText just kept. self:showText(Strings(TEXT_ASK_HEADBUTT), function() self:askYesNo(function(yes) if yes then self:runHeadbutt(cx, cy, mon) end end) end) return true end -- HeadbuttScript: GetPartyNickname, UseHeadbuttText, ShakeHeadbuttTree, and -- only then TreeMonEncounter. The roll comes AFTER the shake, which is why -- the tree rattles even when nothing is home. function World:runHeadbutt(cx, cy, mon) if self.game then -- callasm GetPartyNickname: wStringBuffer2 is the nickname of the mon -- CheckPartyMove left in wCurPartyMon, and {STRBUF} is how TextBox reads -- it back. self.game.stringBuffer = (mon and (mon.nickname or mon.name or mon.species)) or "" end self:showText(Strings(TEXT_USE_HEADBUTT), function() self.headbutt = { x = cx, y = cy, timer = HEADBUTT_SHAKE_FRAMES } -- ShakeHeadbuttTree (engine/events/field_moves.asm:23) hides the BG tree -- and wobbles an OBJ copy of it -- Frameset_HeadbuttTree alternates two -- frames, the second X-flipped, every two frames for the 32 the counter -- runs. There is no per-block OBJ layer here (the map is one baked -- canvas), so the wobble is the frame's, on the same clock and for the -- same 32 frames as the SFX that goes with it. self:earthquake(0x40, HEADBUTT_SHAKE_FRAMES) self:playSfx(SFX_SANDSTORM) end) end function World:updateHeadbutt() local st = self.headbutt if not st then return end if self.textbox or self.choicebox then return end if st.timer > 0 then st.timer = st.timer - 1 return end -- callasm TreeMonEncounter, iffalse .no_battle. Cleared first for the same -- reason the fishing state is: startBattle must not see a busy world. self.headbutt = nil if self:tryHeadbutt(st.x, st.y) == "battle" then return end self:showText(Strings(TEXT_HEADBUTT_NOTHING)) end -- Headbutt. A tree's own map entry decides which of the two tree sets is -- rolled, and whether anything is home at all (engine/events/treemons.asm -- TreeMonEncounter). Returns "battle", "nothing" or nil. function World:tryHeadbutt(cx, cy) local game, map = self.game, self.map if not (game and map and self.encounters) then return nil end local roll = Encounter.treeSlot(self.encounters, map.id, cx, cy, nil) if not roll or not roll.species then return "nothing" end local wild = Mon.new(game.data, roll.species, roll.level) if not wild then return "nothing" end local save = game.save if save then save.pokedex = save.pokedex or { seen = {}, caught = {} } save.pokedex.seen[roll.species] = true end self:startBattle({ wild = wild }) return "battle" end -- ROCK SMASH's wild mon (engine/events/treemons.asm RockMonEncounter), which -- is TreeMonEncounter's twin over a different map table and with a flat roll -- where the tree has its coordinate score: -- -- GetTreeMonSet RockMonMaps the four maps whose rocks hold anything at all -- GetTreeMons that row's TREEMON_SET_* table -- RandomRange 10, cp 4 40 percent, and it sits BETWEEN the two -- SelectTreeMon the set's FIRST list, walked by a 0..99 roll -- -- Two things the twin does NOT do: it writes no wScriptVar (RockSmashScript -- reads its answer back with `readmem wTempWildMonSpecies / iffalse`), and it -- never reaches GetTreeMon's `.rare` skip past the -1, so the second half of -- TREEMON_SET_ROCK is unreachable and only the 90/10 KRABBY / SHUCKLE list can -- come out of a rock. -- -- `random` is injectable for the same reason World:roamRandom is: a driver and -- a test have to be able to pin the 40 percent. Returns the species INDEX the -- cart leaves in wTempWildMonSpecies, or 0 for nothing -- which is exactly what -- the `readmem` below hands back to the script. function World:rockRandom(n) if self.rockmonRandom then return self.rockmonRandom(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 function World:rockMonEncounter() -- `xor a / ld [wTempWildMonSpecies], a / ld [wCurPartyLevel], a` opens the -- routine, so a second smash never inherits the first one's mon. self.tempWildMon = nil local game, map = self.game, self.map if not (game and map and self.encounters) then return 0 end local setName = self.encounters.rocks and self.encounters.rocks[map.id] if not setName then return 0 end local set = self.encounters.treeSets and self.encounters.treeSets[setName] local list = set and set.common if not (list and #list > 0) then return 0 end if self:rockRandom(10) >= 4 then return 0 end -- SelectTreeMon's `.loop: sub [hl] / jr c, .ok`: the chance column is walked -- as a running total until the roll borrows. local value = self:rockRandom(100) local total = 0 local pick for _, row in ipairs(list) do total = total + (row.chance or 0) if value < total then pick = row break end end -- `.ok`'s own `cp -1 / jr z, NoTreeMon`: a list walked off the end is nothing. if not (pick and pick.species) then return 0 end local pokemon = game.data and game.data.pokemon local def = pokemon and pokemon[pick.species] if not (def and def.index) then return 0 end self.tempWildMon = { species = def.index, level = pick.level } return def.index end -- readmem's engine seam. The VM keeps a sparse byte store for the addresses a -- script owns outright (the Goldenrod switches, wMooMooBerries); these are the -- ones the ENGINE writes, where answering out of that store would read back a -- stale 0. RockSmashScript's `readmem wTempWildMonSpecies / iffalse` is the -- whole reason this exists: the byte is written by the callasm one row above. local WRAM_TEMP_WILD_MON_SPECIES = 0xd117 function World:scriptReadMem(addr) if addr == WRAM_TEMP_WILD_MON_SPECIES then return (self.tempWildMon and self.tempWildMon.species) or 0 end return nil end -- .SweetScent (engine/events/sweet_scent.asm): UseSweetScentText, then -- SweetScentEncounter's roll. wFieldMoveSucceeded was already set -- unconditionally by FieldMoves.sweetScentFromMenu, so runFieldMove has no -- refusal branch of its own -- the only question left is whether the -- encounter turns anything up, and that is answered after the button press, -- same as HEADBUTT's shake. function World:runSweetScent(result) local mon = result and result.mon if self.game then -- callasm GetPartyNickname: wStringBuffer2 (and 1 and 3) all hold the -- same nickname, so {STRBUF} reads back UseSweetScentText's -- text_ram wStringBuffer3 line correctly. self.game.stringBuffer = (mon and (mon.nickname or mon.name or mon.species)) or "" end self:showText(Strings(TEXT_USE_SWEET_SCENT), function() if self:sweetScentEncounter() then return end self:showText(Strings(TEXT_SWEET_SCENT_NOTHING)) end) end -- SweetScentEncounter (engine/events/sweet_scent.asm): the same -- CanEncounterWildMon gate a step takes -- grass/water tile, ice, wildoff, -- the CAVE/DUNGEON exemption -- but everything downstream skips its own -- percentage roll. GetMapEncounterRate only has to come back NONZERO, and -- ChooseWildEncounter / ChooseWildEncounter_BugContest then run -- unconditionally: no Encounter.triggers, no BugContest.triggers. -- CheckRepelEffect is never reached at all here -- a REPEL stops a STEP from -- encountering, not the player from choosing to use SWEET SCENT. function World:sweetScentEncounter() local game, player, map = self.game, self.player, self.map if not (game and player and map and self.encounters) then return false end local save = game.save if not (save and save.party and #save.party > 0) then return false end local collision = map:cellCollision(player.cellX, player.cellY) local environment = map.def and map.def.environment if not FieldMoves.canEncounterWildMon( environment, collision, self.noWildEncounters) then return false end -- .BugCatchingContest: `checkflag ENGINE_BUG_CONTEST_TIMER` skips -- GetMapEncounterRate entirely and goes straight to the park's own table, -- same as RandomEncounter's own contest arm above -- and, like that arm, -- ChooseWildEncounter_BugContest never calls CheckEncounterRoamMon. if BugContest.isActive(save) then local roll = self:rollEncounter("contest", "grass", nil, rollContestVanilla) if not roll then return false end local wild = Mon.new(game.data, roll.species, roll.level) if not wild then return false end save.pokedex = save.pokedex or { seen = {}, caught = {} } save.pokedex.seen[roll.species] = true self:startBattle({ wild = wild, contest = true }) return true end -- SweetScentEncounter goes through ChooseWildEncounter like a step does, so -- a swarm overrides the map's list here too -- and, unlike a step, it never -- reaches CheckRepelEffect, which is why SWEET SCENT works through a REPEL. local tables = self:wildTables() local onWater = FieldMoves.encounterTable(collision) == "water" local rate = onWater and Encounter.waterRate(tables, map.id) or Encounter.grassRate(tables, map.id, self.daytime) if not (rate and rate > 0) then return false end -- CheckEncounterRoamMon, the first thing ChooseWildEncounter itself does: -- a beast REPLACES the map's own slot rather than adding to it. local met = Roamers.checkEncounter(save, map.id, onWater, self:roamRandom()) if met then local beast = Roamers.beginBattle(save, met.index, game.data) if beast then save.pokedex = save.pokedex or { seen = {}, caught = {} } save.pokedex.seen[beast.species] = true self:startBattle({ wild = beast, roaming = met.index }) return true end end local roll = self:rollEncounter("sweet_scent", onWater and "water" or "grass", tables, onWater and rollWaterVanilla or rollGrassVanilla) if not roll then return false end -- ChooseWildEncounter's Unown arm: a chamber with no puzzle solved yet -- stays empty for SWEET SCENT too. local monOpts = nil if roll.species == Unown.SPECIES then local flags = self:engineFlags() if not Unown.anyUnlocked(flags) then return false end monOpts = { dvs = Unown.wildDVs(flags, Mon.randomDVs) } end local wild = Mon.new(game.data, roll.species, roll.level, monOpts) if not wild then return false end save.pokedex = save.pokedex or { seen = {}, caught = {} } save.pokedex.seen[roll.species] = true self:startBattle({ wild = wild }) return true end -- ---- field moves ---------------------------------------------------------- -- -- The world half of engine/events/overworld.asm. src/world/gen2/FieldMoves.lua -- holds every decision (badge, party move, tile, refusal line); this holds the -- effects, because those are the only part that needs a map, a sprite and a -- frame clock. The split is exactly the ASM's own: the *Function routines are -- pure jumptable arithmetic over wFieldMoveData and then hand a SCRIPT to -- QueueScript, and it is the script that touches the world. -- The index of the block cell (cx, cy) sits in, inside map.def.blocks, plus -- the block id there. GetBlockLocation, minus the WRAM border arithmetic the -- port has no buffer for. function World:blockIndexAt(cx, cy) local map = self.map if not (map and map.width and map.height and map.def) then return nil end local bx, by = math.floor(cx / 2), math.floor(cy / 2) if bx < 0 or by < 0 or bx >= map.width or by >= map.height then return nil end local index = by * map.width + bx + 1 return index, (map.def.blocks or {})[index] end -- Everything a FieldMoves routine reads, gathered once. GetFacingTileCoord is -- folded in: the facing cell is the player's own plus their direction, and -- wTileUp is the cell above them whichever way they face. function World:fieldContext(mon) local p, map = self.player, self.map local save = self.game and self.game.save local facing = (p and p.facing) or "down" local d = Map.DELTA[facing] or Map.DELTA.down local fx, fy = p.cellX + d[1], p.cellY + d[2] local blockIndex, blockId = self:blockIndexAt(fx, fy) return { save = save, party = (save and save.party) or {}, mon = mon, facing = facing, facingX = fx, facingY = fy, facingColl = map:cellCollision(fx, fy), playerColl = map:cellCollision(p.cellX, p.cellY), upColl = map:cellCollision(p.cellX, p.cellY - 1), tileset = map.def and map.def.tileset, facingBlock = blockId, facingBlockIndex = blockIndex, environment = map.def and map.def.environment, -- EscapeRopeOrDig's .CheckCanDig also refuses on a zeroed dig triple; -- digFromMenu reads this rather than re-deriving the banked warp. canEscapeRope = self:escapeRopeTarget() ~= nil, playerState = self.playerState, strengthActive = self.strengthActive, -- 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), } end -- CutDownTreeOrGrass / DisappearWhirlpool: one entry of the loaded map's block -- buffer is overwritten, the tilemap is redrawn from it and GetMovementPermissions -- reruns -- which is why a cut tree stops blocking the step immediately. -- -- The original id is kept so setMap can put it back: the cart's buffer is -- refilled from ROM by LoadMapAttributes on every map load, so a cut tree is -- standing again the next time the map is walked into. function World:replaceBlock(index, blockId) local map = self.map if not (map and index and blockId) then return false end local blocks = map.def and map.def.blocks if not (blocks and blocks[index]) then return false end local mapId = map.id local edits = self.blockEdits[mapId] if not edits then edits = {} self.blockEdits[mapId] = edits end if edits[index] == nil then edits[index] = blocks[index] end blocks[index] = blockId map.blocks = blocks self:refreshMapImages() -- Gen 1's four payload keys off OverworldState:replaceBlock. Every Gen 2 -- block edit lands here -- World:changeBlock's script `changeblock`, CUT, -- WHIRLPOOL and the map callbacks all call through it -- so bx/by are -- recovered from the flat buffer index the cart addresses blocks by, and -- `index` is carried alongside for a listener that wants the raw one. if Runtime.wants("world.block_replaced") then local zero = index - 1 Runtime.emit("world.block_replaced", { mapId = mapId, bx = zero % map.width, by = math.floor(zero / map.width), block = blockId, index = index, }) end return true end -- Drop every baked canvas of ONE map. imageFor keys by map, daytime, COLOR -- mode and cave-flicker phase, so a block edit invalidates a handful of entries -- and there is no reason to throw the neighbours' bakes away with them. function World:dropMapImages(mapId) if not mapId then return end local prefix = mapId .. "|" for key in pairs(self.mapImages) do if key:sub(1, #prefix) == prefix then self.mapImages[key] = nil end end -- The anim cell lists and the bake palettes ride the same keys. for _, store in ipairs({ self.animCells, self.bgSets }) do for key in pairs(store) do if key:sub(1, #prefix) == prefix then store[key] = nil end end end end -- LoadMapAttributes' refill, for every map the session has edited. Neighbour -- strips share the same buffer on the cart, so a connection crossing reloads -- them too: this runs on any setMap, seamless or not. -- -- The bakes go with the blocks. A canvas is baked off map.def.blocks and -- cached under a key that knows nothing about them, so putting a CUT tree back -- without dropping the stump's bake left the tree cut for the rest of the -- session -- and a MAPCALLBACK_TILES map, whose blocks are rewritten on every -- single load, would have frozen on whichever answer it baked first. function World:restoreBlocks() local any = false for mapId, edits in pairs(self.blockEdits) do local def = self.maps and self.maps[mapId] local blocks = def and def.blocks if blocks then for index, original in pairs(edits) do blocks[index] = original any = true end self:dropMapImages(mapId) end self.blockEdits[mapId] = nil end return any end function World:restoreObjectSpawns() -- engine/overworld/map_setup.asm:78 local spawns = self.objectSpawns if not spawns then return end for mapId, byIndex in pairs(spawns) do local def = self.maps and self.maps[mapId] local objects = def and def.objects if objects then for key, xy in pairs(byIndex) do local obj for _, row in ipairs(objects) do if (row.index or 0) == key then obj = row break end end if obj then obj.x, obj.y = xy[1], xy[2] end local npc = self.npcPool and self.npcPool[string.format("%s_obj_%d", mapId, key)] if npc then npc.cellX, npc.cellY = xy[1], xy[2] npc.px, npc.py = xy[1] * 16, xy[2] * 16 npc.homeX, npc.homeY = xy[1], xy[2] npc.moving = false npc.progress = 0 npc.targetX, npc.targetY = nil, nil end end end spawns[mapId] = nil end end -- Drop the loaded map's baked canvases and bake again. Same shape as what -- pollTimeOfDay does when the clock rolls the palette over; a block edit -- invalidates the bake for the same reason a palette change does. A world with -- nothing baked yet (a headless test, or the first load of a session) has -- nothing to refresh, and the one bake setMap is about to do covers it. function World:refreshMapImages() if not self.mapImage then return false end self:dropMapImages(self.map and self.map.id) self.mapImage = self:imageFor(self.map.id) self:rebuildNeighbors() return true 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 def = self.sprites and self.sprites[name] if def and self.player then self.player:setSprite(def) self:applySpritePalette(self.player) end end -- ---- the seven effects ---------------------------------------------------- -- Script_Cut: GetPartyNickname, UseCutText, then CutDownTreeOrGrass. The -- block swap happens when the box closes, not when it opens, so the tree is -- still standing behind the line that says it was cut. function World:runCut(result) self:setNickname(result.mon) self:showText(Strings(result.text), function() self:replaceBlock(result.blockIndex, result.replacement) self:playSfx(SFX_PLACE_PUZZLE_PIECE_DOWN) end) end -- Script_UsedWhirlpool, which is Script_Cut with DisappearWhirlpool and -- PlayWhirlpoolSound (a bare SFX_SURF) in place of the snip. function World:runWhirlpool(result) self:setNickname(result.mon) self:showText(Strings(result.text), function() self:replaceBlock(result.blockIndex, result.replacement) self:playSfx(SFX_SURF) end) end -- Script_UseFlash: the text plays SFX_FLASH from inside itself -- (UseFlashTextScript's text_asm), and BlindingFlash then sets -- STATUSFLAGS_FLASH_F and reloads the palettes. Setting the flag is all there -- is to it: Palettes.daytimeFor already turns a flashed PALETTE_DARK map into -- a NITE one, which is the cart's own .UsedFlash arm. function World:runFlash(result) self:playSfx(SFX_FLASH) self:showText(Strings(result.text), function() self.flashUsed = true if self:applyPalettes() then self:refreshMapImages() end end) end -- UsedSurfScript: the line, then wPlayerState becomes the surf state, the -- sprite follows it, the map music restarts (surfing has its own theme) and -- SurfStartStep walks one slow step into the water. Getting ON is a scripted -- step, which is why it never rolls an encounter. function World:runSurf(result) self:setNickname(result.mon) self:showText(Strings(result.text), function() self:applyPlayerState(result.state) local audio = self.game and self.game.data and self.game.data.audio if audio and audio.runtime and self.map then -- SpecialMapMusic (home/audio.asm:397) Music.playMap(self.game.data, self.map.id, nil, FieldMoves.isSurfing(self.playerState)) end if self.player and self.player.scriptStep then self.player:scriptStep(self.player.facing) end self.fieldMove = { phase = "step" } end) end -- Script_UsedStrength: SetStrengthFlag runs FIRST (callasm, before the text), -- then " used STRENGTH!", the mon's cry, `pause 3`, and -- " can move boulders." function World:runStrength(result) self.strengthActive = true self.strengthMon = result.mon self:setNickname(result.mon) self:showText(Strings(result.text), function() self:playMonCry(result.mon) self.fieldMove = { phase = "strength", timer = STRENGTH_PAUSE_FRAMES, text = result.after, } end) end -- Script_UsedWaterfall: the line, SFX_BUBBLEBEAM, and then a loop of one -- turn_waterfall UP step at a time. -- -- .CheckContinueWaterfall writes wScriptVar = 0 while the player is STILL on a -- waterfall tile and 1 once they are off it, and the script's `iffalse .loop` -- loops on 0. Read the flag the other way round and the climb stops on the -- first step. function World:runWaterfall(result) self:setNickname(result.mon) self:showText(Strings(result.text), function() self:playSfx(SFX_BUBBLEBEAM) self.fieldMove = { phase = "waterfall" } self:waterfallStep() end) end function World:waterfallStep() local p = self.player if not p then return end p.facing = "up" if p.scriptStep then p:scriptStep("up") end end -- callasm GetPartyNickname: {STRBUF} is the nickname of the mon CheckPartyMove -- picked, and TextBox reads it back off game.stringBuffer. function World:setNickname(mon) if not self.game then return end local name = (mon and (mon.nickname or mon.name or mon.species)) or "" self.game.stringBuffer = name -- engine/events/overworld.asm:1339 if self.vm then self.vm.stringBuffer = name end end function World:playMonCry(mon) local data = self.game and self.game.data local cries = data and data.audio and data.audio.cries local species = mon and mon.species if not (cries and species and cries[species]) then return end self.lastSfx = Sound.playCry(data, species) end -- ---- the boulder ---------------------------------------------------------- function World.isStrengthBoulder(npc) local def = npc and npc.def return def ~= nil and def.movement == SPRITEMOVEDATA_STRENGTH_BOULDER end -- .CheckStrengthBoulder (engine/overworld/player_movement.asm), reached from -- .CheckNPC when something is standing in the way. With -- BIKEFLAGS_STRENGTH_ACTIVE set and the object standing still, its facing is -- pointed the way the player walked and BOULDER_MOVING_F goes up; -- MovementFunction_Strength then steps it, but only if -- CanObjectMoveInDirection agrees. -- -- The player BUMPS either way: .CheckNPC's "2" is treated exactly like a -- solid NPC, so the boulder moves and the player stays where they were. function World:tryPushBoulder(dir, cx, cy) if not self.strengthActive then return false end local npc = self:npcAt(cx, cy) if not (npc and World.isStrengthBoulder(npc)) or npc.moving then return false end local d = Map.DELTA[dir] local tx, ty = cx + d[1], cy + d[2] if not self.map:isWalkable(tx, ty) then return false end for _, e in ipairs(self.entities or {}) do if e ~= npc and e.cellX == tx and e.cellY == ty then return false end end npc:scriptStep(dir) self:playSfx(SFX_STRENGTH) -- Gen 1's four payload keys. Divergence, deliberate: Gen 1 emits from the -- scriptMove completion callback, once the boulder has settled; Gold's -- MovementFunction_Strength has no such callback, so this fires as the push -- is committed and x/y are the cell the boulder is stepping ONTO -- which is -- the same pair Gen 1's listener eventually sees. if Runtime.wants("world.boulder_moved") then Runtime.emit("world.boulder_moved", { mapId = self.map.id, npcId = (npc.def and npc.def.index or 0) + 1, x = tx, y = ty }) end return true end -- ---- running one -------------------------------------------------------- -- QueueScript, as far as the port is concerned: the result the model handed -- back is turned into the script that carries it out. function World:runFieldMove(result) local action = result and result.action if action == "cut" then self:runCut(result) elseif action == "whirlpool" then self:runWhirlpool(result) elseif action == "flash" then self:runFlash(result) elseif action == "surf" then self:runSurf(result) elseif action == "strength" then self:runStrength(result) elseif action == "waterfall" then self:runWaterfall(result) elseif action == "fly" then self:openFlyMap() elseif action == "headbutt" then self:runHeadbutt(result.facingX, result.facingY, result.mon) elseif action == "sweetscent" then self:runSweetScent(result) elseif action == "escaperope" or action == "dig" then self:runDigEscape(result) elseif action == "teleport" then self:runTeleport(result) else return false end return true end -- .UsedEscapeRopeScript / .UsedDigScript (engine/events/overworld.asm -- EscapeRopeOrDig): the used-item line -- GetPartyNickname fills {STRBUF} for -- DIG, the rope addresses {PLAYER} -- then the shared warp tail. The target -- was resolved when the action was queued; a load the map churn has since -- invalidated falls back to re-resolving, and to nothing at worst. function World:runDigEscape(result) self:setNickname(result.mon) self:showText(Strings(result.text), function() local destMapId, destWarp = result.destMap, result.destWarp if not (destMapId and destWarp) then destMapId, destWarp = self:escapeRopeTarget() end if destMapId then self:runEscapeWarp(destMapId, destWarp) end end) end -- TeleportFunction's .TeleportScript: the return line, then WarpToSpawnPoint -- with `newloadmap MAPSETUP_TELEPORT` -- the same landing a whiteout takes, -- which is exactly what World:warpToSpawn resolves (blackoutmod override -- first, then the SPAWN_* table). PLAYER_NORMAL first, so a teleport off a -- bike arrives on foot the way `loadvar VAR_MOVEMENT, PLAYER_NORMAL` leaves -- it. The teleport spin, like the dig spin, is sprite work and not ported. function World:runTeleport(result) self:setNickname(result.mon) self:showText(Strings(result.text), function() self:playSfxNamed("Sfx_WarpTo", SFX_WARP_TO) self:applyPlayerState(FieldMoves.PLAYER_NORMAL) self:runMapSetup(MAPSETUP_TELEPORT, function() self:warpToSpawn() return true end) end) end -- The block a cut or whirlpool result edits. The model works in block IDs -- because that is what field_move_blocks.asm is written in; the index comes -- from the context that produced the result, so it is stapled on here rather -- than threaded through the pure half. local function withBlockIndex(result, ctx) if result and result.replacement then result.blockIndex = ctx.facingBlockIndex end if result then result.facingX, result.facingY = ctx.facingX, ctx.facingY end return result end -- PokemonActionSubmenu's MONMENU_FIELD_MOVE arm: the party list has already -- chosen the mon, so the badge is checked with the noisy CheckBadge and the -- move itself is taken on trust. Returns the result so the caller (the party -- menu) knows whether it was refused. -- -- A success is QUEUED, not run. Every *Function ends in QueueScript and the -- queued script only runs once the menus are gone -- which is the whole point: -- the party list is still on the screen at the moment CUT is chosen, and -- " used CUT!" belongs over the overworld. Try*OW is the other half of -- that distinction and uses CallScript, which runs on the spot. function World:useFieldMove(moveId, mon) if not (self.map and self.player) then return nil end if self.battleActive or self:busy() then return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE } end local ctx = self:fieldContext(mon) local result = withBlockIndex(FieldMoves.fromMenu(moveId, ctx), ctx) result.mon = result.mon or mon if result.ok then self.queuedFieldMove = result elseif result.text then self:showText(Strings(result.text)) end return result end -- The queued script, once the world owns the frame again. function World:runQueuedFieldMove() local queued = self.queuedFieldMove if not queued or self:busy() then return false end self.queuedFieldMove = nil return self:runFieldMove(queued) end -- TryTileCollisionEvent's arms, in its order, each one a "did this take the A -- press" answer. A result with `ask` opens AskXScript's yesorno first; one -- without runs (or refuses) straight away. function World:runOverworldFieldMove(result) if not result or not result.took then return false end if not result.ok then if result.text then self:showText(Strings(result.text)) end return true end if result.ask then self:showText(Strings(result.ask), function() self:askYesNo(function(yes) -- AskCutScript's `iffalse .declined` and friends: NO is a plain -- closetext, and CUT's own map check only happens after the YES. if yes and result.action then self:runFieldMove(result) end end) end) return true end self:runFieldMove(result) return true end function World:tryCutOW() local ctx = self:fieldContext() if not Permissions.isCutTree(ctx.facingColl) then return false end return self:runOverworldFieldMove( withBlockIndex(FieldMoves.tryCutOW(ctx), ctx)) end function World:tryWhirlpoolOW() local ctx = self:fieldContext() if not Permissions.isWhirlpool(ctx.facingColl) then return false end return self:runOverworldFieldMove( withBlockIndex(FieldMoves.tryWhirlpoolOW(ctx), ctx)) end function World:tryWaterfallOW() local ctx = self:fieldContext() if not Permissions.isWaterfall(ctx.facingColl) then return false end return self:runOverworldFieldMove( withBlockIndex(FieldMoves.tryWaterfallOW(ctx), ctx)) end -- TrySurfOW is the last arm and the only one with no tile test of its own: -- anything the earlier arms did not claim reaches it, and it fails silently -- unless the facing tile really is water. function World:trySurfOW() local ctx = self:fieldContext() return self:runOverworldFieldMove( withBlockIndex(FieldMoves.trySurfOW(ctx), ctx)) end -- AskStrengthScript, which a boulder's own script jumps to (jumpstd -- StrengthBoulderScript). Reached by talking to a boulder, not by walking -- into one -- the walk is the push, and this is the offer to switch STRENGTH -- on in the first place. function World:tryStrengthOW() local ctx = self:fieldContext() return self:runOverworldFieldMove( withBlockIndex(FieldMoves.tryStrengthOW(ctx), ctx)) end -- ---- fly ------------------------------------------------------------------ -- FlyMap's region split, which reads the PLAYER's landmark and nothing else: -- the first 46 landmarks are Johto, the rest Kanto. function World:region() local landmarks = self.landmarks and self.landmarks.landmarks local id = self.map and self.map.def and self.map.def.landmark local entry if type(id) == "string" then entry = landmarks and landmarks[id] elseif type(id) == "number" then local order = self.landmarks and self.landmarks.order entry = order and landmarks and landmarks[order[id + 1]] end local index = (entry and entry.index) or (type(id) == "number" and id) or 0 if index == 94 then return "johto" end -- LANDMARK_FAST_SHIP return index >= 46 and "kanto" or "johto" end function World:flyPoints() return FieldMoves.flyPoints( self.game and self.game.save, self.landmarks, self:region()) end -- .FlyScript: WarpToSpawnPoint, then `newloadmap MAPSETUP_TELEPORT` brings the -- map up with the player back in PLAYER_NORMAL. MapSetupScript_Teleport opens -- on FadeOutToWhite and falls through into _Warp, so flying is bracketed by the -- same pair of fades a door is, which is where the two fly animations ride: -- `lift` below hands them to World:updateMapSetup. function World:flyTo(spawnId) local spawn = self.landmarks and self.landmarks.spawns and self.landmarks.spawns[spawnId] if not (spawn and spawn.map and self.maps and self.maps[spawn.map]) then return false end self:applyPlayerState(FieldMoves.PLAYER_NORMAL) local ok = self:runMapSetup(MAPSETUP_TELEPORT, function() return self:setMap(spawn.map, spawn.x, spawn.y, "down") end) -- FlyFromAnim / FlyToAnim ride the setup script's own two fades: the take-off -- lift under the fade out, the landing under the fade in. if self.mapSetup then self.mapSetup.lift = true end return ok end -- _FlyMap: the town map with the cursor locked to visited flypoints, A takes -- the one under it and B leaves. -- -- The screen itself is src/ui/gen2/Pokegear.lua's fly mode (Pokegear.FLY_MAP), -- which draws the same town map the MAP card does with TownMapBubble's -- "Where?" plate over it instead of the card strip. A run with no love at all -- (a headless probe) has no screen to push, so the destinations are offered -- one at a time through the same yesorno box every other field move uses. function World:openFlyMap() local points = self:flyPoints() if #points == 0 then return false end -- Loaded on demand and through pcall: a headless run has no love, and this -- is the only place in the world that reaches for a screen module by hand. local okGear, Pokegear = pcall(require, "src.ui.gen2.Pokegear") if okGear and Pokegear.FLY_MAP and self.game and self.game.stack then Screens.push(self.game, "Gen2Pokegear", { save = self.game.save, currentLandmark = self:currentLandmarkId(), fly = points, onFly = function(spawnId) self.game.stack:pop() self:flyTo(spawnId) end, onClose = function() self.game.stack:pop() end, }) return true end self:askFlyPoint(points, 1) return true end function World:askFlyPoint(points, index) local row = points[index] if not row then return end local name = (row.name or row.landmark):gsub("\n", " ") self:showText(Strings(FieldMoves.TEXT.ASK_FLY_TO, name), function() self:askYesNo(function(yes) if yes then self:flyTo(row.spawn) else self:askFlyPoint(points, index + 1) end end) end) end function World:currentLandmarkId() local def = self.map and self.map.def local id = def and def.landmark if type(id) == "string" then return id end local order = self.landmarks and self.landmarks.order return order and id and order[id + 1] or nil end -- ---- the per-frame half --------------------------------------------------- -- The tail of the scripts above: a `pause`, the surf step landing, and the -- waterfall climb's loop. Ticked alongside the fishing cast and the tree -- shake for the same reason they are -- on the cart these are script commands, -- so the world is frozen for them. function World:updateFieldMove() local st = self.fieldMove if not st then return end if self.textbox or self.choicebox then return end if st.timer and st.timer > 0 then st.timer = st.timer - 1 return end if st.phase == "strength" then self.fieldMove = nil if st.text then self:showText(Strings(st.text)) end return end if self.player and self.player.moving then return end if st.phase == "waterfall" then -- Still on a waterfall tile: another turn_waterfall UP. local coll = self.map:cellCollision(self.player.cellX, self.player.cellY) if FieldMoves.waterfallContinues(coll) then self:waterfallStep() return end end self.fieldMove = nil end -- Which song this battle fights to (engine/battle/start_battle.asm -- PlayBattleMusic), and the facts BattleMusic needs to pick it: the class the -- opponent belongs to, the member inside it (only RIVAL2 reads that), the -- map's landmark for RegionCheck, and the clock. function World:battleMusicContext(opts) local members = self.constants and self.constants.trainerClassMembers local trainer = opts and opts.trainer return { class = trainer and trainer.classId, member = trainer and trainer.memberId, members = trainer and trainer.classId and members and members[trainer.classId] or nil, landmark = self.map and self.map.def and self.map.def.landmark, daytime = self.daytime, } end function World:playBattleMusic(opts) local data = self.game and self.game.data local audio = data and data.audio if not (audio and audio.songs) then return nil end local song = BattleMusic.battleSong(self:battleMusicContext(opts)) if not (song and audio.songs[song]) then return nil end Music.play(data, song, true, { reason = "battle" }) return song end -- DoBattleTransition. Returns true when the wipe took the screen, false when -- there is nothing to wipe (a headless run, or a battle started before the map -- is up) and the battle should just come straight in. function World:pushBattleTransition(battle, opts, onDone) local game = self.game if not (game and game.stack and self.map) then return false end Screens.push(game, "Gen2BattleTransition", { world = self, trainer = opts and opts.trainer and true or false, environment = self.map.def and self.map.def.environment, playerLevel = battle and battle.player and battle.player.level, enemyLevel = battle and battle.enemy and battle.enemy.level, onDone = onDone, }) return true end -- Push the battle screen. Kept here rather than in Game2 so a trainer -- script and a grass step start a battle the same way. -- -- The order is the cart's: DoBattleTransition owns the screen first, and only -- when it has finished blacking the overworld out does the battle screen come -- up. PlayBattleMusic runs BEFORE the transition, which is why the battle -- theme is already going while the wipe is still spinning. function World:startBattle(opts, onDone) local game = self.game if not (game and game.stack) then if onDone then onDone("win") end return false end local battle = Battle.new({ data = game.data, -- BATTLETYPE_TUTORIAL fights with an EMPTY party: engine/battle/core.asm -- jumps straight to BattleMenu without a send-out, so the DUDE's demo has -- no player mon at all and the caller passes its own party in. party = opts.party or (game.save and game.save.party) or {}, wild = opts.wild, trainer = opts.trainer, -- wMoney and wMomsMoney, for WinTrainerBattle's payout -- (src/battle/gen2/Prize.lua). The battle writes both accounts itself, -- the way the cart's own trainer-defeated arm does, so a fight that never -- comes back through the script still pays. save = game.save, -- BATTLETYPE_ROAMING: the save slot whose HP byte the end of the battle -- writes back. Only World:tryWildEncounter sets it. roaming = opts.roaming, -- wBattleType, when the script armed one: the FORCESHINY / TRAP -- no-escape rules live in Battle:tryRun and the force-switch handler. battleType = opts.battleType, }) self:playBattleMusic(opts) local function pushBattle() -- wBattleMode, as far as the overworld is concerned: a battle screen this -- world pushed is up. The PACK opened from inside one must not take the -- field path, because a rod is ITEMMENU_NOUSE in battle -- (data/items/attributes.asm) and the field path would fish from under it. self.battleActive = true Screens.push(game, "Gen2BattleState", { battle = battle, save = game.save, music = self:battleMusicContext(opts), -- BATTLETYPE_CONTEST: the park ball menu, the held catch and the draw on -- the last ball. Only World:tryContestEncounter sets it. contest = opts.contest, -- BATTLETYPE_TUTORIAL: the DUDE's back-pic, no player HUD, the forced -- POKE BALL and the throw that cannot fail. Only -- World:startCatchTutorial sets it. tutorial = opts.tutorial, onDone = function(outcome) self.battleActive = nil game.stack:pop() -- wBattleResult (constants/battle_constants.asm): WIN 0, LOSE 1, DRAW 2. -- The port never forfeits or draws a battle, so "lose" is the only -- other outcome startBattle's onDone hands back; VAR_BATTLERESULT -- reads this back masked with ~BATTLERESULT_BITMASK, same as the cart. self.lastBattleResult = (outcome == "lose") and 1 or 0 -- BattleEnd_HandleRoamMons, which runs on the way out of EVERY wild -- battle: this one banks the beast's HP and moves it, and any other -- wild battle takes the 1-in-16 `.not_roaming` roll that moves them -- anyway. Before the loss warp, because the walk is computed against -- the map the player is standing on and the warp is about to change it -- to the spawn point. if battle.wild then self:roamMonsAfterBattle(battle.roaming, outcome, battle.enemy and battle.enemy.hp) -- Script_reloadmapafterbattle's `.was_wild` arm: `bit -- BATTLERESULT_BOX_FULL, a / jr z, .done`, then a LoadMemScript of -- Script_SpecialBillCall (engine/overworld/scripting.asm:1097-1104), -- which is `callasm .LoadBillScript` -- LoadCallerScript with -- e = PHONE_BILL -- falling into Script_ReceivePhoneCall -- (engine/phone/phone.asm:441-446). BattleState sets boxFilled -- exactly where .SendToPC sets the bit. -- -- NOT one of the SPECIALCALL_* rows, so Phone.queueSpecialCall is the -- wrong door: this is a received call wearing Bill's own contact and -- his CALLER script. LoadMemScript is a deferral, which is what -- World:queuedScript is here: the ring lands on the first overworld -- frame after the reload rather than over the battle screen. It also -- carries no `pause 30`, unlike the special-call wrappers. if battle.boxFilled then local Phone = require("src.core.gen2.Phone") local call = Phone.loadCallerScript( Phone.PHONECONTACT_BILL, "incoming", "caller") if self.vm and call.scriptKey and self.vm.scripts[call.scriptKey] then self.vm.curPhoneCaller = call.contact local name, className = Phone.contactName(call.contact, game.data and game.data.trainers) self.queuedScript = require("src.core.gen2.PhoneRing").script(call, name, className) end end end -- A loss warps home with a healed party, the way a whiteout does -- -- because it IS one: Script_reloadmapafterbattle's `cp LOSE` jumps -- into Script_BattleWhiteout (engine/events/whiteout.asm), which is -- Script_Whiteout with a different BG map call in front of it. So -- the losing half of the wallet goes here too, in the cart's order: -- HealParty, then HalveMoney, then GetWhiteoutSpawn, then the warp. -- The Bug Contest is the one exception the script itself carries -- (`checkflag ENGINE_BUG_CONTEST_TIMER / iftrue .bug_contest` skips -- both callasms), so a wipe in the park costs nothing. -- -- BATTLETYPE_CANLOSE is the other exception, and it is the battle -- engine's own: LostBattle (engine/battle/core.asm) prints the loss -- text for this type and returns with the player exactly where they -- fought, and maps/CherrygroveCity.asm follows the battle with -- `reloadmap` and its .AfterYourDefeat arm -- the rival's shove and -- walk-off play at the battle site, and `special HealParty` at -- .FinishRival is what heals the party, not a whiteout. Warping here -- moved the loser to the spawn point and then ran that walk-off over -- whatever stood there. if outcome == "lose" and opts.battleType ~= BATTLETYPE_CANLOSE then self:healParty() if not BugContest.isActive(game.save) then CallAsm.run(self, "HalveMoney") CallAsm.run(self, "GetWhiteoutSpawn") end -- The second of the two blackout seams, same as Gen 1's pair (the -- poison walk in World:whiteOut is the other), and guarded for the -- same reason its twin is. if Runtime.wants("world.blacked_out") then Runtime.emit("world.blacked_out", { save = game.save, healTarget = self:healPoint() }) end self:warpToSpawn() end -- RestartMapMusic: the map theme comes back with the overworld, over -- whatever the battle left playing (the victory jingle loops until -- exactly here). Unconditional, because the one-shot -- wDontPlayMapMusicOnReload the Sudowoodo and Snorlax battles set is -- not consumed here at all: `dontrestartmapmusic` is the command AFTER -- `startbattle` (maps/CherrygroveCity.asm:124-126), so it has not even -- run yet, and it is the `reloadmap` behind it that owns the silence -- (World:forceMapMusic). Reading the flag here instead left it set -- with nothing to consume it, and the NEXT battle -- very often the -- repeatable Route 29 catch tutorial -- ended with the map music -- stopped for good. A wild encounter has no reload behind it and -- still needs this restore. self:restoreMapMusic() -- BugCatchingContestBattleScript's own tail, which runs after -- `reloadmapafterbattle`: out of park balls sends the player back to -- the gate rather than back into the grass. if opts.contest and self:bugContestBattleOver() then return end -- Script_reloadmapafterbattle's .notblackedout arm: `bit -- BATTLESCRIPT_WILD_F, d` is SET for a trainer (Script_loadtrainer -- writes (1 << 7) | 1, Script_loadwildmon only (1 << 7) -- the flag's -- name reads backwards), so it is a won TRAINER battle and nothing -- else that gives Mom a chance to spend the savings. if opts.trainer and outcome ~= "lose" then self:momTriesToBuy() end -- A scripted battle resumes the VM here; the trainer flag and the -- after-battle text are the commands waiting on the other side. if onDone then onDone(outcome) end end, }) end local transition = self:pushBattleTransition(battle, opts, pushBattle) if not transition then pushBattle() end return true end -- `startbattle` from a script: a trainer record (class + member) or a -- loadwildmon pair. The VM is parked on the yield until onDone fires, so the -- rest of the trainer script (flag set, after-battle text) runs on return. function World:startScriptedBattle(record, wild, onDone) local data = self.game and self.game.data local opts = {} if record then -- The battle screen names a trainer the way the cart does: class then -- name, "YOUNGSTER JOEY". local display = record.name if record.className and record.className ~= "" then display = record.className .. " " .. (record.name or "") end local bareName = record.name -- PlaceEnemysName (home/text.asm:327), which is what the character -- resolves to: with wTrainerClass RIVAL1 or RIVAL2 it prints wRivalName -- ALONE, no class prefix and no parties-table name, because every rival row -- in data/trainers/parties.asm literally carries `db "?@"`. wRivalName is -- what `special NameRival` (maps/ElmsLab.asm:515) wrote; before that screen -- has been through it still holds InitializeNPCNames' "???", which is the -- name the Cherrygrove theft battle prints, so "???" is the fallback here -- and NOT NameRival's own SILVER default. -- record.className is deliberately left alone: it is the class key -- Palettes.trainerColors and BattleMusic read, not a display string. So is -- Trainers.lookup's own name -- GetTrainerName (engine/battle/ -- read_trainer_party.asm:326) has no rival arm, so `gettrainername` really -- does answer "?". if record.classId == "RIVAL1" or record.classId == "RIVAL2" then local save = self.game and self.game.save display = (save and save.rival and save.rival.name) or "???" bareName = display end opts.trainer = { class = record.class, -- The class and member CONSTANTS (RIVAL2, RIVAL2_2_CHIKORITA), which is -- what PlayBattleMusic's ladder compares against. classId = record.classId, memberId = record.id, name = display, trainerName = bareName, className = record.className, party = Trainers.party(data, record), -- TRNATTR_BASE_REWARD, the third byte of the class's seven-byte -- attributes row. ComputeTrainerReward multiplies it by the LAST -- party row's level, which is why the party above and this byte have -- to travel together. baseMoney = record.baseMoney, -- The rest of the class's attributes row (data/trainers/attributes.asm): -- the AI personality bytes AIActionCount / TRNATTR_AI_MOVE_WEIGHTS reads -- through Ai.flagsOf, and the two TRNATTR_ITEM slots AI_TryItem may -- reach for. Trainers.lookup already builds both -- `items` as a fresh -- copy, precisely so a battle using one up does not empty the class -- record -- and this is the only place a scripted battle is built, so -- leaving them off here is what made every trainer in the game fight -- with no personality and no potions. attributes = record.attributes, items = record.items, } elseif wild and wild.species then local id, def = speciesByIndex(data and data.pokemon, wild.species) -- InitEnemyMon `.NotRoaming` / BATTLETYPE_FORCESHINY: the DV pair is -- forced to ATKDEFDV_SHINY $EA / SPDSPCDV_SHINY $AA (Attack 14, the -- rest 10) before stats are built, which is the whole of what makes the -- Red Gyarados red -- and caught, it keeps the DVs and stays shiny. local monOpts if self:battleType() == BATTLETYPE_FORCESHINY then monOpts = { dvs = { attack = 14, defense = 10, speed = 10, special = 10 } } end opts.wild = id and Mon.new(data, id, wild.level or 5, monOpts) or nil -- InitEnemyMon's `.WildItem` / BATTLETYPE_FORCEITEM: Item1 is handed over -- unconditionally, no roll, which is the only wild-item path modeled -- -- see Mon.new's own note on why the general 25%/8% roll is not. Read -- here rather than after startBattle, because scriptVars[VAR_BATTLETYPE] -- is cleared the moment this function hands off to it. if opts.wild and self:battleType() == BATTLETYPE_FORCEITEM then local given = def and def.items and def.items[1] if given then opts.wild.item = given end end end if not (opts.trainer and #opts.trainer.party > 0) and not opts.wild then if onDone then onDone("win") end return false end -- wBattleType, which `writevar VAR_BATTLETYPE / loadvar BATTLETYPE_*` armed: -- FORCEITEM 10 (Lugia, Ho-Oh, the Red Gyarados), FORCESHINY 7 (Lake of Rage), -- TRAP 9 (the Rocket base), CANLOSE 1 (the Cherrygrove rival). It is a -- ONE-SHOT on the cart -- BattleStart_TrainerBattle / StartWildBattle reset -- it -- so the value is taken and cleared here and handed to the battle, -- which is the half that still has to act on each case. opts.battleType = self:battleType() self.scriptVars[VAR_BATTLETYPE] = nil return self:startBattle(opts, onDone) end -- `catchtutorial BATTLETYPE_TUTORIAL`: CatchTutorial (engine/events/ -- catch_tutorial.asm) around a real battle. The name swap, the DUDE's pack -- and the option override live in src/core/gen2/CatchTutorial.lua; the battle -- itself is the ordinary wild path with an empty party, which is what makes it -- start on the battle menu with no mon out. -- -- The wild mon is the one the `loadwildmon RATTATA, 5` in front of the command -- left behind, and it is built through Mon.new like every other Gen 2 party -- member so it arrives with a real moveset and real stats. function World:startCatchTutorial(wild, battleType, onDone) local game = self.game local data = game and game.data local save = game and game.save local mon if wild and wild.species then local id = speciesByIndex(data and data.pokemon, wild.species) mon = id and Mon.new(data, id, wild.level or 5) or nil end if not mon then -- No wild mon means the script never ran `loadwildmon`, which no reachable -- `catchtutorial` does. Hand the script straight back rather than opening -- an empty battle. if onDone then onDone() end return false end local state = CatchTutorial.begin(save, game and game.options) return self:startBattle({ wild = mon, -- The DUDE has no mon of his own: the battle opens on the menu. party = {}, tutorial = true, battleType = battleType or CatchTutorial.BATTLETYPE_TUTORIAL, }, function() CatchTutorial.finish(save, game and game.options, state) if onDone then onDone() end end) end -- Every `loadtrainer` and every `gettrainername` comes through here, which is -- why the CAL2 redirect lives in TrainerHouse.lookup rather than at the -- Trainer House's own call site: ReadTrainerParty tests the class before it -- indexes the parties table, so the redirect has to sit in front of the table -- for anything that can name CAL2, not just for that one script. function World:trainerParty(class, member) return TrainerHouse.lookup(self.game and self.game.data and self.game.data.trainers, self.game and self.game.save, class, member) end -- MomTriesToBuySomething (engine/events/mom_phone.asm), reached from the -- trainer arm of `reloadmapafterbattle`. src/core/gen2/MomShopping.lua owns -- the two shopping lists and the balance walk; this is the map half plus the -- call itself. -- -- The cart does not SPEAK here: it `LoadMemScript`s the phone call and lets -- the overworld pick it up, which is what wMapReentryScriptQueueFlag at the -- top of the routine is guarding against. World:queuedScript is that same -- deferral -- runQueuedScript drains it on the first frame the overworld owns -- with no text box open -- so the four lines land after the trainer's own -- after-battle script has finished rather than on top of it. -- -- The ring is the call's own: MomTriesToBuySomething's .Script is `callasm -- .ASMFunction / farsjump Script_ReceivePhoneCall` with Mom's pages queued in -- wCallerContact, so the queued rows here ride the same ring chrome every -- other incoming call does (src/core/gen2/PhoneRing.lua) with PHONE_MOM as -- the caller. The four writetexts are the whole of Mom_GetScriptPointer's -- script either way. function World:momTriesToBuy() local save = self.game and self.game.save if not save then return nil end local def = self.map and self.map.def local purchase = MomShopping.tryBuy(save, { events = self.events, data = self.game and self.game.data, -- RandomRange returns 0..n-1. random = function(n) return math.random(n) - 1 end, -- GetMapPhoneService: a map with no reception `ret`s before the balance -- is looked at, so Mom simply tries again after the next trainer. phoneService = (def == nil) or (def.phoneService ~= false), }) if not purchase then return nil end local script = {} for _, page in ipairs(MomShopping.pages(purchase)) do -- `rawtext`, not `writetext`: these six strings sit in data/text/ -- common_1.asm behind a phone script the extractor never walks, so there -- is no text.lua key to name them by. script[#script + 1] = { op = "rawtext", text = page } end script[#script + 1] = { op = "end" } local Phone = require("src.core.gen2.Phone") if self.vm then self.vm.curPhoneCaller = Phone.PHONECONTACT_MOM end self.queuedScript = require("src.core.gen2.PhoneRing").script( { scriptKey = script }, Phone.NON_TRAINER_NAMES[Phone.PHONECONTACT_MOM]) -- A doll changes what stands in the bedroom, and the room is rebuilt from -- the flags on a MAP LOAD -- so nothing has to be dropped here, the same -- way Decorations' own menu leaves it to the PC's warp. return purchase end -- PlayTrainerEncounterMusic: the short jingle that plays while the trainer -- walks up to you, one per class out of data/trainers/encounter_music.asm. -- It is NOT the battle theme -- PlayBattleMusic replaces it a moment later -- when the transition starts. -- -- A cache built before the table was extracted has no `encounterMusic`, so the -- old behaviour (the shared Johto trainer battle theme) is the fallback. function World:playTrainerEncounterMusic(class) local data = self.game and self.game.data local audio = data and data.audio if not (audio and audio.songs) then return end local entry = Trainers.classIndex(data.trainers)[class] local song = entry and entry.encounterMusic if song and song ~= "Music_Nothing" and audio.songs[song] then Music.play(data, song, true, { reason = "trainer_encounter" }) return end for _, name in ipairs({ "Music_JohtoTrainerBattle", "Music_KantoTrainerBattle" }) do if audio.songs[name] then Music.play(data, name, true, { reason = "trainer_encounter" }) return end end end -- showemote: the bubble sits one cell above the object for `frames` frames. -- Drawn by World:draw over the map, so it rides the same camera as the NPC. function World:showEmote(emote, object, frames) local sheet = self.emoteImages and self.emoteImages[ self.emoteOrder and self.emoteOrder[(emote or 0) + 1]] -- LAST_TALKED (-2) is the object the script is about; everything else is a -- plain object id. local ent = (object == -2) and (self.talkNpc or self.trainerNpc) or self:objectEntity(object or 0) if not (sheet and ent) then return end self.emote = { image = sheet, entity = ent, left = frames or 30 } end -- HealMachineAnim (engine/events/heal_machine_anim.asm), the light show the -- nurse runs between "we'll need your POKeMON" and "thank you for waiting". -- -- .PC_ElmsLab_OAM / .HOF_OAM transcribed as screen positions (each dbsprite's -- raw OAM bytes minus the hardware's 8/16 OAM origin). The cart lays them -- out at fixed screen coordinates because the player is always standing on -- the machine's own talk cell, BG-aligned at (64,64) -- the same anchor -- Camera:follow keeps -- so each element's world position is the player's -- cell corner plus (sx - 64, sy - 64), and the overlay stays glued to the -- machine at any zoom. -- -- `machine` is the two $7c tiles .PC_LoadBallsOntoMachine places before the -- party loop; `balls` fill one per party member in OAM order (top pair -- first), the right column OAM_XFLIPped. wScriptVar picks the table -- -- HEALMACHINE_POKECENTER 0, HEALMACHINE_ELMS_LAB 1 (the same table shifted -- by `bcpixel 2, 4`), HEALMACHINE_HALL_OF_FAME 2 (all balls, fanning out -- from the machine's centre line). local HEAL_MACHINE_LAYOUT = { [0] = { machine = { { 26, 16 }, { 30, 16 } }, balls = { { 24, 22 }, { 32, 22, true }, { 24, 27 }, { 32, 27, true }, { 24, 32 }, { 32, 32, true } }, }, [2] = { balls = { { 73, 44 }, { 78, 44 }, { 69, 43 }, { 82, 43 }, { 65, 41 }, { 85, 41 } }, }, } do local pc = HEAL_MACHINE_LAYOUT[0] local elm = { machine = {}, balls = {} } for i, t in ipairs(pc.machine) do elm.machine[i] = { t[1] + 16, t[2] + 32 } end for i, b in ipairs(pc.balls) do elm.balls[i] = { b[1] + 16, b[2] + 32, b[3] } end HEAL_MACHINE_LAYOUT[1] = elm end -- The special is BLOCKING: `onDone` is what resumes the script, so the -- nurse's next line never comes up over the machine still running. The -- cart's first guard is `ld a, [wPartyCount] / and a / ret z`, and a cache -- with no sheet (from before the extractor carried it) resumes the same way -- rather than hanging the script. function World:startHealMachineAnim(animType, onDone) local party = self.game and self.game.save and self.game.save.party local layout = HEAL_MACHINE_LAYOUT[animType or 0] or HEAL_MACHINE_LAYOUT[0] if not (party and #party > 0 and self.healMachineImage) then if onDone then onDone() end return end local p = self.player self.healAnim = { layout = layout, hof = animType == 2, balls = math.min(#party, #layout.balls), lit = 0, timer = 0, phase = "balls", flashes = 0, rotation = 0, px = p and p.cellX * 16 or 0, py = p and p.cellY * 16 or 0, onDone = onDone, } end -- One frame of the machine, on the cart's own timeline: each party member's -- ball lands with SFX_SECOND_PART_OF_ITEMFINDER then DelayFrames 30, then -- MUSIC_HEAL plays over .FlashPalettes8Times -- eight rotations of the OBJ -- palette ten frames apart. The Hall of Fame arm swaps the jingle for -- SFX_GAME_FREAK_LOGO_GS and rings SFX_BOOT_PC once the flashing stops. -- The special returns after the last flash's delay, which is when the balls -- clear -- the cart leaves its OAM to the overworld redraw the ended script -- allows, and this is that same moment. function World:stepHealAnim() local ha = self.healAnim if not ha then return end ha.timer = ha.timer + 1 if ha.phase == "balls" then if ha.lit == 0 or ha.timer >= 30 then ha.timer = 0 if ha.lit < ha.balls then ha.lit = ha.lit + 1 self:playSfxNamed("Sfx_SecondPartOfItemfinder", SFX_SECOND_PART_OF_ITEMFINDER) else ha.phase = "flash" if ha.hof then self:playSfxNamed("Sfx_GameFreakLogoGs", SFX_GAME_FREAK_LOGO_GS) else -- .PlayHealMusic. playOnce hands the map its theme back when the -- jingle ends; the script's own `pause 30` + RestartMapMusic -- behind the special covers a cache whose song is missing. Music.playOnce(self.game.data, "Music_HealPokemon") end end end elseif ha.phase == "flash" then -- FlashPalettes8Times flashes FIRST and then delays, so the first -- rotation lands on the same frame the jingle starts. if ha.flashes == 0 or ha.timer >= 10 then ha.timer = 0 if ha.flashes >= 8 then if ha.hof then self:playSfxNamed("Sfx_BootPc", SFX_BOOT_PC) end local done = ha.onDone self.healAnim = nil if done then done() end return end ha.flashes = ha.flashes + 1 -- The CGB arm of .FlashPalettes rotates the four colours one slot per -- flash; 8 % 4 lands the palette back where it started, exactly the -- way the DMG arm's XOR does after an even count. ha.rotation = ha.flashes % 4 end end end -- The overlay: OBJs on the cart, drawn over everyone the same way the emote -- bubble is. The flash is the palette rotation expressed as an rBGP-shaped -- byte through GbcPalette.remap, so the DMG and CLASSIC colour modes keep a -- visible flash instead of collapsing to their fixed shades. function World:drawHealAnim(s, billboard) local ha = self.healAnim local img = self.healMachineImage if not (ha and img) then return end local G = love.graphics if not self.healMachineQuads then local w, h = img:getWidth(), img:getHeight() self.healMachineQuads = { machine = G.newQuad(0, 0, 8, 8, w, h), -- $7c, the machine's light ball = G.newQuad(8, 0, 8, 8, w, h), -- $7d, one ball } end local cam = self.camera local ox, oy = ha.px - 64, ha.py - 64 local function screen(sx, sy) return math.floor((ox + sx - cam.x) * s), math.floor((oy + sy - cam.y) * s) end local function body() local shaded = false local pal = self.healMachinePalette if pal and GbcPalette.available() then local byte = 0 for i = 0, 3 do byte = byte + ((i + ha.rotation) % 4) * (4 ^ i) end shaded = GbcPalette.useRaw( GbcPalette.remap(GbcPalette.resolve(pal), byte)) end G.setColor(1, 1, 1, 1) for _, t in ipairs(ha.layout.machine or {}) do local tx, ty = screen(t[1], t[2]) G.draw(img, self.healMachineQuads.machine, tx, ty, 0, s, s) end for i = 1, ha.lit do local b = ha.layout.balls[i] if b then local bx, by = screen(b[1] + (b[3] and 8 or 0), b[2]) G.draw(img, self.healMachineQuads.ball, bx, by, 0, b[3] and -s or s, s) end end if shaded then GbcPalette.clear() end end if billboard then local anchor = (ha.layout.machine or ha.layout.balls)[1] local fx, fy = screen(anchor[1] + 4, anchor[2] + 8) billboard(fx, fy, body) else body() end end -- The PokemonCenterPC / PlayersHousePC specials: push that PC's screen. -- The script is parked on the VM's own resume, so the PC's own B closes it and -- the rest of the script continues. -- `opts.house` is _PlayersHousePC rather than PokemonCenterPC, and the two -- are different screens on the cart: the center's is the whose-PC top menu -- (src/ui/gen2/CenterPcMenu.lua), the bedroom's is the item PC alone -- (src/ui/gen2/ItemPcMenu.lua, PLAYERSPC_HOUSE) with PLAYERSPCITEM_DECORATION -- on its list and no box access. The bedroom's `onDone` gets the c the cart -- returns -- TRUE only if a decoration moved, which is what makes -- PlayersHousePCScript take its `.Warp` arm. function World:openPc(opts) opts = opts or {} local game = self.game if not (game and game.stack) then if opts.onDone then opts.onDone(false) end return false end Screens.push(game, opts.house and "Gen2ItemPcMenu" or "Gen2CenterPcMenu", { save = game.save, house = opts.house, events = self.events, onClose = function(changed) game.stack:pop() if opts.onDone then opts.onDone(changed and true or false) end end, }) return true end -- ToggleDecorationsVisibility (PLAYERS_HOUSE_2F's MAPCALLBACK_NEWMAP). Both -- halves of each row land here: the sprite byte in wVariableSprites and the -- object's own event flag, which is what decides whether the object is built -- at all. -- -- Called from a map callback, so it must not rebuild anything itself: setMap -- has not laid the objects out yet when NEWMAP runs, and World:setVariableSprite -- would rebuild people against the map being LEFT. function World:toggleDecorationsVisibility() local state = Decorations.state(self.game and self.game.save) for _, row in ipairs(Decorations.visibility(state)) do self.events:set(row.flag, row.hidden) if not row.hidden then self.variableSprites[row.sprite] = row.byte end end end -- ToggleMaptileDecorations (the MAPCALLBACK_TILES one). The blocks go through -- World:changeBlock, so the bake this map already has is dropped and the edit -- is undone by the next LoadMapAttributes -- which is right: the callback runs -- again on every load and repaints from the same eight bytes. function World:toggleMaptileDecorations() local state = Decorations.state(self.game and self.game.save) for _, tile in ipairs(Decorations.tiles(state)) do self:changeBlock(tile.x, tile.y, tile.block) end -- SetPosterVisibility, inline in the same routine: a bare wall is not -- readable, so the poster's BGEVENT_IFSET flag follows the slot. self.events:set(Decorations.EVENT_PLAYERS_ROOM_POSTER, Decorations.posterVisible(state)) end -- Script_pokemart -> OpenMartDialog (engine/items/mart.asm). The clerk's whole -- conversation is one blocking screen on the cart, so the script parks on the -- VM's resume and the mart's own QUIT is what lets the next command run -- -- exactly the arrangement openPc uses for the storage system. function World:openMart(martType, martId, onDone) local game = self.game if not (game and game.stack) then if onDone then onDone() end return false end Screens.push(game, "Gen2MartMenu", { save = game.save, items = game.data and game.data.items, marts = self.marts, martType = martType, martId = martId, -- text.lua carries the clerk's whole conversation, seeded by name. text = self.text, onClose = function() game.stack:pop() if onDone then onDone() end end, }) return true end -- `loadmenu` then `verticalmenu` / `_2dmenu`: the static menu a script puts up. -- The extractor follows the MenuHeader pointer now, so the header arrives with -- its box, its flags and its item strings; a cache built before that has only -- the raw address, and with nothing to draw the honest answer is the one -- StaticMenuJoypad gives for B. -- -- The menu is NOT opaque: the cart leaves the text box the script opened -- underneath it (`opentext` / `writetext` / `loadmenu`), which is why the -- vending machines read as a price list over a speech line. function World:openScriptMenu(header, style, onChoose) local game = self.game local items = header and (header.items or header.gridItems) -- The balance box a `special` left standing (showCoins / showMoney above). -- Consumed here whether or not the menu opens: CloseWindow takes the box down -- with the menu, so it must not survive into the next one. local balance = self.scriptBalance self.scriptBalance = nil if not (game and game.stack) or not items or #items == 0 then if onChoose then onChoose(0) end return false end Screens.push(game, "Gen2ScriptMenu", { header = header, style = style, balance = balance, save = game.save, onChoose = function(index) game.stack:pop() if onChoose then onChoose(index) end end, }) return true end -- `trade trade_id` -> NPCTrade (engine/events/npc_trade.asm), the whole -- blocking conversation off data/events/npc_trades.asm. The extractor emits -- that table and the 5x3 TradeTexts block now, so all six trades have both -- their rules and their lines; before, the command was a no-op. -- -- NPCTrade writes no wScriptVar, so the script carries on either way and there -- is nothing to answer. function World:openNpcTrade(id, onDone) local game = self.game if not (game and game.stack) then if onDone then onDone() end return false end Screens.push(game, "Gen2TradeMenu", { trade = id, save = game.save, eventTables = self.eventTables, onClose = function() game.stack:pop() if onDone then onDone() end end, }) return true end -- `elevator floor_list` -> Elevator (engine/events/elevator.asm). -- -- Three things in order: find the row for the floor the player got in on -- (wBackupMapNumber, which is the map they warped in FROM), ask which floor, -- and then Elevator_GoToFloor -- which does not warp. It writes the chosen -- row's warp number and map into wBackupWarpNumber / wBackupMapGroup / -- wBackupMapNumber, and the elevator's own door, a `warp_event` whose -- destination warp is -1, reads them when the player walks out. -- -- `.FindCurrentFloor` failing is `scf`: the command quits with no menu at all, -- which is what happens if a script ever runs `elevator` somewhere that is not -- in its own list. function World:openElevator(floors, onDone) local game = self.game local rows = {} for _, row in ipairs(floors or {}) do if row.destMap and self.maps[row.destMap] then rows[#rows + 1] = row end end local origin for _, row in ipairs(rows) do if row.destMap == self.backupMapId then origin = row break end end if not (game and game.stack) or not origin then if onDone then onDone(nil) end return false end Screens.push(game, "Gen2ElevatorMenu", { floors = rows, currentMap = self.backupMapId, floorNames = self.eventTables.floorNames, onDone = function(row) game.stack:pop() if row then self.backupWarp = { warp = row.destWarp, map = row.destMap } end if onDone then onDone(row) end end, }) return true end -- Pokecenter heal (the HealParty special): full HP, full PP, no status, for -- every party member. The cart does this in one pass over wPartyMons, and it -- is also what a whiteout does before warping home. function World:healParty() local save = self.game and self.game.save for _, mon in ipairs((save and save.party) or {}) do mon.hp = mon.maxHp or mon.hp mon.status = nil mon.statusTurns = nil for _, move in ipairs(mon.moves or {}) do if type(move) == "table" then move.pp = move.maxPp or move.pp end end end end -- The rival naming screen (the NameRival special, engine/events/specials.asm): -- `farcall _NamingScreen` does not return until the keyboard closes, and only -- then does InitName fall back to the version default for an empty entry -- so -- the officer's "OK! So " line always prints the freshly typed name. -- `onDone` is that return: H.NameRival parks the script on it, and the screen's -- close is what resumes the officer. function World:nameRival(onDone) local game = self.game if not (game and game.stack) then if onDone then onDone() end return end local data = game.data or {} local sprites = data.gen2Sprites local rival = sprites and sprites.SPRITE_RIVAL Screens.push(game, "Gen2NamingScreen", { type = "rival", menuGfx = data.gen2MenuGfx, iconPath = rival and rival.image or nil, iconColors = data.gen2Palettes and Palettes.spritePalette(data.gen2Palettes, self.daytime or "DAY", rival) or nil, onDone = function(name) game.stack:pop() local save = game.save if save then save.rival = save.rival or {} -- NameRival ends on `ld hl, wRivalName / ld de, .DefaultName / call -- InitName`, and .DefaultName is "SILVER@" on Gold -- (engine/events/specials.asm:80-91). The default has to be written -- HERE rather than ridden in from the seed: wRivalName starts as -- InitializeNPCNames' "???" (src/core/gen2/Save.lua), which is what the -- pre-naming Cherrygrove battle prints. -- -- _InitString defines blank as "zero or more spaces followed by a -- null" (home/string.asm:6-30), so an entry of nothing but spaces -- -- typeable, since the keyboard's blank cells are real characters -- -- falls back exactly the same way an empty one does. if name and name:gsub(" ", "") ~= "" then save.rival.name = name else save.rival.name = "SILVER" end end if onDone then onDone(name) end end, }) end -- home/map.asm LoadMapAttributes .SetSpawn, ported. See the call site in -- setMap for why it was missing and what that cost. `def` is the map being -- loaded; self.map is still the one being left. function World:updateWhiteoutSpawn(def, mapId) if not def then return end local prev = self.map and self.map.def if not prev then return end -- CheckOutdoorMap on the map being left, CheckIndoorMap on the one being -- entered, then its tileset. All three, in that order. if not (prev.environment == "ROUTE" or prev.environment == "TOWN") then return end if def.environment ~= "INDOOR" then return end if def.tileset ~= "TILESET_POKECENTER" then return end -- `ld a, [wPrevMapGroup] / ld [wLastSpawnMapGroup], a`: what is stored is the -- map being LEFT -- the town or route outside the door -- not the Pokecenter. -- data/maps/spawn_points.asm is keyed that way (`spawn PALLET_TOWN, 5, 6`), -- and it is what makes the Indigo Plateau centre work: it is entered from -- ROUTE_23, and SPAWN_INDIGO is ROUTE_23 (9,6). local save = self.game and self.game.save if save then save.blackoutMap = prev.id or mapId end end -- GetWhiteoutSpawn's answer WITHOUT taking it: where a whiteout would land, -- as { map, x, y, spawn }, or nil when nothing resolves. Split out of -- warpToSpawn (which is now its only other caller) so world.blacked_out can -- carry the same healTarget key Gen 1's OverworldState:healPoint fills, read -- from one place rather than two that can drift. function World:healPoint() local save = self.game and self.game.save -- wLastSpawnMapGroup / wLastSpawnMapNumber, written by `blackoutmod`, are -- read BEFORE the SPAWN_* table: the Fast Ship and Mr. Pokemon's house set -- them so that losing at sea or out past Cherrygrove does not respawn the -- player somewhere they cannot leave. local override = save and save.blackoutMap if override then -- GetWhiteoutSpawn's own lookup: the stored map is matched against -- SpawnPoints, and it is that row which carries the coordinates. The table -- is keyed by the OUTDOOR map (`spawn PALLET_TOWN, 5, 6`), which is exactly -- what World:updateWhiteoutSpawn stores, so this resolves a Pokecenter -- visit to the right doorstep -- SPAWN_INDIGO is ROUTE_23 (9,6), the -- approach to the Plateau centre. local spawns = self.landmarks and self.landmarks.spawns if type(spawns) == "table" then for id, row in pairs(spawns) do if type(row) == "table" and row.map == override and self.maps[row.map] then return { map = row.map, x = row.x, y = row.y, spawn = id } end end end -- Not a spawn point: `blackoutmod`'s other users (the Fast Ship cabins) -- name maps that are not in the table at all, so fall back to the map -- itself rather than sending the player home from the middle of the sea. if self.maps[override] then local def = self.maps[override] local warp = def.warps and def.warps[1] return { map = override, x = (warp and warp.x) or 0, y = (warp and warp.y) or 0 } end end local spawnId = (save and save.spawn) or SPAWN_HOME local spawn = self.landmarks and self.landmarks.spawns and self.landmarks.spawns[spawnId] if not (spawn and spawn.map and self.maps[spawn.map]) then return nil end return { map = spawn.map, x = spawn.x, y = spawn.y, spawn = spawnId } end -- WarpToSpawnPoint: back to the last Pokecenter (or the bedroom before one is -- visited), which is where a whiteout lands. function World:warpToSpawn() local target = self:healPoint() if not target then return end self:setMap(target.map, target.x, target.y, "down") end function World:playCry(speciesIndex) local data = self.game and self.game.data if not data then return end local id = speciesByIndex(data.pokemon, speciesIndex) if id then self.lastSfx = Sound.playCry(data, id) end end function World:playSfx(sfxId) local data = self.game and self.game.data local audio = data and data.audio if not audio then return end local name = audio.sfxOrder and audio.sfxOrder[(sfxId or 0) + 1] if name and audio.sfx and audio.sfx[name] then self.lastSfx = Sound.play(data, name) end end function World:playMusicId(musicId) local data = self.game and self.game.data local audio = data and data.audio if not (audio and audio.runtime) then return end local name = audio.musicOrder and audio.musicOrder[(musicId or 0) + 1] -- Script_playmusic with MUSIC_NONE is how a script silences the map ahead -- of its own cue -- the nurse's `playmusic MUSIC_NONE` right before the -- heal machine -- so it is a real stop, not a skipped play. if (musicId or 0) == 0 or name == "Music_Nothing" then Music.stop() return end if name and audio.songs and audio.songs[name] then Music.play(data, name, true, { reason = "script_playmusic" }) end end function World:tryCoordScript() if self:busy() or not self.map or not self.player or not self.vm then return false end if self.player.moving then return false end local scene = self:scene() local x, y = self.player.cellX, self.player.cellY for _, ev in ipairs(self.map.def.coordEvents or {}) do if ev.x == x and ev.y == y and (ev.sceneId or 0) == scene and ev.scriptKey then self.talkNpc = nil return self.vm:start(ev.scriptKey) end end return false end function World:trySceneScript() if not self.map or not self.vm then return false end local scene = self:scene() local scenes = self.map.def.sceneScripts local entry = scenes and scenes[scene] if not entry then -- Lua arrays are 1-based; scene 0 may live at scenes[0] or scenes[1] if -- extracted as a list. Prefer explicit sceneId field. if type(scenes) == "table" then for _, s in pairs(scenes) do if type(s) == "table" and s.sceneId == scene and s.scriptKey then entry = s break end end end end local key = entry and (entry.scriptKey or entry.script) if type(key) == "number" then return false end if key then self.talkNpc = nil return self.vm:start(key) end return false end -- `stay` is the VM's one-command lookahead (Vm:textStays): the next script -- command is `yesorno`, so this text ends in `done` and the cart never took the -- box down before InitYesNoTextBoxParameters put the prompt over it. The box -- is left standing and World:askYesNo stacks the choice on THIS box, instead of -- the box popping on a button press the cart never asked for and the question -- being re-printed underneath the prompt. -- -- `hold` is the cart's `pause` when that pause sits INSIDE the box rather than -- after it: FindItemInBallScript is `writetext .FoundItemText / playsound -- SFX_ITEM / pause 60 / itemnotify` (engine/events/misc_scripts.asm:13-17) and -- none of those commands takes the box down. It cannot be run as a VM `pause` -- here, because Game2:update stops at the top state -- while ANY box is on the -- stack the overworld and the VM under it do not tick at all -- so the wait has -- to be counted by the box itself. Frames, already doubled by Vm:pauseFrames -- the way Script_pause's `ld c, 2 / call DelayFrames` doubles the operand. function World:showText(body, onDone, stay, hold) local game = self.game -- The box a PREVIOUS `stay` left standing (TextBox's contract is "whoever -- pushed it owns the pop", src/render/TextBox.lua:40). `yesorno` consumes it -- in World:askYesNo; the other consumer is the next page of the same cart -- MapTextbox -- FindItemInBallScript's itemnotify line, after the box has -- held through `playsound / pause 60`. The pop and the push happen inside -- ONE frame, so no frame ever renders the bare overworld between two pages -- the cart never took a box down between; a frame that did would both tear -- the box down visibly and let Game2's play clock, which only pauses while a -- state is on the stack, come off pause mid-pickup. local held = self.stayedTextBox self.stayedTextBox = nil if not game or not game.stack then if onDone then onDone() end return end if held and game.stack:top() == held then game.stack:pop() end self.textbox = true -- Kept for `yesorno`, which re-shows this page under the prompt. self.lastText = body if stay then local box local left = (hold or 0) > 0 and hold or nil box = TextBox.new(game, body, nil, { -- stay.onShown fires on the frame the last page finishes typing, which is -- where PrintText returns on the cart (#591). stay = { onShown = function() self.stayedTextBox = box -- The `hold` above. TextBox's own page/CONT block counter is the one -- per-frame gate a finished box already has (src/render/TextBox.lua -- :237), and clearing `stayShown` re-arms this hook for the frame it -- drains on -- so the box sits there, read, for the length of the -- cart's pause and THEN hands the script back. Dropping the latch is -- safe because a stay box reads no input and pops for nothing. if left then box.holdFrames = left box.stayShown = false left = nil return end if onDone then onDone() end end }, }) game.stack:push(box) return end game.stack:push(TextBox.new(game, body, function() self.textbox = nil if onDone then onDone() end end)) end function World:pooledNpc(mapId, obj) if not self.sprites or not obj or not obj.sprite then return nil end -- An object whose `sprite` is a NUMBER names a wVariableSprites slot rather -- than a sheet (Route 36's Sudowoodo carries $f4), and only `variablesprite` -- can say what stands there. Unfilled, it stays nil and nothing spawns -- -- which is exactly what the cart draws before the scene script runs. local name = self:resolveSprite(obj.sprite) if not name then return nil end -- The SPRITE_POKEMON ids DO have a row (the extractor follows SpriteMons and -- points them at the mon's menu icon). The day-care pair cannot have one -- -- its species is whatever is being bred -- so resolveSprite answers with the -- built def itself rather than a name. local spriteDef = type(name) == "table" and name or self.sprites[name] if not spriteDef then return nil end local key = string.format("%s_obj_%d", mapId, obj.index or 0) local npc = self.npcPool[key] if not npc then npc = NPC.new(mapId, obj, spriteDef) self.npcPool[key] = npc -- applyPalettes only runs on map entry and once a second -- (PALETTE_POLL_STEPS), so an NPC created after that -- anything an event -- flag reveals part way through a map's life -- would draw with an -- unbaked sheet until the next poll came round. That is a full second of -- a grey character standing in a coloured room. self:applySpritePalette(npc) -- Gen 1's pooledNPC emits the same three keys from the same place -- the -- pool miss, so one object announces itself once per run and not on every -- seam crossing. `runtime` is always false here: Gold has no runtime-object -- system (see WorldAPI:spawnNpc), so every object came out of the map def. if Runtime.wants("world.npc_spawned") then Runtime.emit("world.npc_spawned", { mapId = mapId, npcId = key, runtime = false }) end end return npc end -- CheckObjectTime (home/map_objects.asm), the mask LoadObjectMasks computes -- beside CheckObjectFlag: an object_event's two hour bytes decide whether it -- is on the map AT ALL right now. macros/scripts/maps.asm spells the encoding -- out: h1 < h2 shows the object from h1 to h2 (inclusive both ends), h1 > h2 -- HIDES it strictly between h2 and h1, h1 == h2 always shows, and h1 == -1 -- turns h2 into a MORN/DAY/NITE bitmask (-1 = always). This is what keeps the -- Goldenrod pharmacist single (two rows on one tile, one DAY one NITE), what -- empties the Mt Moon gift shop at night, and what keeps exactly one of the -- three time-of-day Moms in the kitchen. -- -- The mask compares against the CLOCK's time of day (GetTimeOfDay reads -- hHours), never wTimeOfDayPalset -- an indoor map pinned to PALETTE_DAY still -- swaps its night staff -- which is why this reads World:hour and not -- World:timeOfDayId. local MORN_MASK, DAY_MASK, NITE_MASK = 1, 2, 4 function World:clockTimeMask() -- TimesOfDay (engine/rtc/rtc.asm): 0400-0959 morn, 1000-1759 day, else nite. local hour = self:hour() if hour >= 4 and hour < 10 then return MORN_MASK end if hour >= 10 and hour < 18 then return DAY_MASK end return NITE_MASK end function World:objectTimeVisible(obj) local hours = obj and obj.hours if not hours then return true end local h1 = hours[1] or -1 local h2 = hours[2] or -1 if h1 == -1 then if h2 == -1 then return true end local mask = self:clockTimeMask() return h2 % (mask * 2) >= mask end if h1 == h2 then return true end local hour = self:hour() if h1 < h2 then return hour >= h1 and hour <= h2 end return hour >= h1 or hour <= h2 end -- wObjectMasks, one byte per object_event (home/map.asm:1534 CheckObjectMask). -- Tri-state here: true masked, false unmasked, nil "no mask loaded", which is -- the only case that falls back to deriving visibility from the event flag. function World:objectMaskKey(obj, index) return string.format("%s:%d", self.map.id, (obj and obj.index) or index or 0) end -- MaskObject / UnmaskObject (home/map.asm:1542, :1548): ONE byte, this -- object's. `maskScripted` remembers that a script wrote it, so the hour poll -- below cannot resurrect somebody a scene took off the map. function World:setObjectMask(obj, index, masked) self.objectMasks = self.objectMasks or {} self.maskScripted = self.maskScripted or {} local key = self:objectMaskKey(obj, index) self.objectMasks[key] = masked and true or false self.maskScripted[key] = true end -- LoadObjectMasks (engine/overworld/map_objects_2.asm:1): ByteFill over the -- whole array, then one GetObjectTimeMask / CheckObjectFlag per object. This -- is the ONLY place the event flags decide who is on the map; after it, an -- `appear` or `disappear` moves its own byte and a plain `setflag` moves -- nobody until the next load. -- -- LoadMapObjects runs it AFTER MAPCALLBACK_OBJECTS (engine/overworld/ -- map_setup.asm:78-82), so a callback's appear/disappear counts only through -- the flag it sets -- which is why the reset is unconditional here. The hour -- poll passes keepScripted, since it stands in for a reload the cart does not -- actually run. function World:loadObjectMasks(opts) opts = opts or {} local scripted = (opts.keepScripted and self.maskScripted) or {} local masks = opts.keepScripted and (self.objectMasks or {}) or {} local def = self.map and self.map.def for index, obj in ipairs((def and def.objects) or {}) do local key = self:objectMaskKey(obj, index) if not scripted[key] then masks[key] = not (self.events:objectVisible(obj.eventFlag) and self:objectTimeVisible(obj)) end end self.objectMasks = masks self.maskScripted = scripted end -- Current-map NPCs + visual-only ghosts on neighbor strips (Gen 1 pattern). function World:rebuildPeople(opts) opts = opts or {} -- Anything this function did not put in the list is a GUEST: the follower -- (src/world/gen2/Follower.lua) or a mod's own entity. A rebuild runs on -- every zoom and time-of-day roll, so wiping guests loses a follower at the -- top of the hour; Gen 1 rebuilds on map load only and never had to say it. local made = self.peopleFromMap or {} local guests for _, npc in ipairs(self.npcs or {}) do -- Only this map's: a warp respawns rather than dragging one across a seam. if not made[npc] and (npc.mapId == nil or npc.mapId == self.map.id) then guests = guests or {} guests[#guests + 1] = npc end end local fromMap = {} self.peopleFromMap = fromMap if not opts.seamless or not self.npcPool then self.npcPool = {} end self.npcs = {} self.entities = {} if self.player then table.insert(self.entities, self.player) end for _, obj in ipairs(self.map.def.objects or {}) do -- wObjectMasks is what says who is standing here (CheckObjectMask, -- home/map.asm:1534): the flags only reach it through LoadObjectMasks, at -- map load. A world that never ran setMap has no masks at all, so nil -- falls back to the derivation LoadObjectMasks would have done. local masked = self.objectMasks and self.objectMasks[self:objectMaskKey(obj)] if masked == nil then masked = not (self.events:objectVisible(obj.eventFlag) and self:objectTimeVisible(obj)) end if not masked then local npc = self:pooledNpc(self.map.id, obj) if npc then fromMap[npc] = true table.insert(self.npcs, npc) table.insert(self.entities, npc) end end end for _, npc in ipairs(guests or {}) do table.insert(self.npcs, npc) table.insert(self.entities, npc) end self.ghosts = {} for _, nb in ipairs(self.neighbors) do local def = self.maps[nb.id] if def then local tileset = self.tilesets[def.tileset] local ghostMap = tileset and Map.new(def, tileset) or nil local peers = {} for _, obj in ipairs(def.objects or {}) do if self.events:objectVisible(obj.eventFlag) and self:objectTimeVisible(obj) then local npc = self:pooledNpc(nb.id, obj) if npc and ghostMap then table.insert(peers, npc) table.insert(self.ghosts, { npc = npc, map = ghostMap, ox = nb.ox, oy = nb.oy, peers = peers, }) end end end end end end -- Mod-spawned map objects. The Gen 1 arm (OverworldState:addRuntimeObject) -- appends straight onto the map def's object list, and the same move works -- here: World.maps is one table for the whole run and rebuildPeople reads -- self.map.def.objects, so an appended object is pooled, drawn, walked and -- talked to like an extracted one, and survives a map reload. Not serialized: -- a permanent NPC belongs in a maps patch, this is for actors the mod respawns -- on map.entered. A runtime object carries no eventFlag, so LoadObjectMasks' -- derivation leaves it visible. function World:addRuntimeObject(mapId, objDef, owner) local def = self.maps and self.maps[mapId] if not def then return nil, "unknown map: " .. tostring(mapId) end def.objects = def.objects or {} local index = 0 for _, obj in ipairs(def.objects) do if (obj.index or 0) > index then index = obj.index end end objDef.index = index + 1 objDef.runtime = true objDef.owner = owner table.insert(def.objects, objDef) local npcId = mapId .. "_obj_" .. objDef.index if self.map and self.map.id == mapId then self:rebuildPeople({ seamless = true }) end return npcId end -- Imported objects are refused, and so is another mod's. function World:removeRuntimeObject(npcId, owner) for mapId, def in pairs(self.maps or {}) do for i, obj in ipairs(def.objects or {}) do if obj.runtime and mapId .. "_obj_" .. obj.index == npcId then if owner ~= nil and obj.owner ~= owner then return nil, "not owned by " .. tostring(owner) end table.remove(def.objects, i) if self.npcPool then self.npcPool[string.format("%s_obj_%d", mapId, obj.index)] = nil end if self.map and self.map.id == mapId then self:rebuildPeople({ seamless = true }) end return true end end end return nil, "no such runtime object: " .. tostring(npcId) end -- IsNPCAtCoord (engine/overworld/npc_movement.asm), which is what BOTH the -- step's `.CheckNPC` and the A press's CheckFacingObject ask -- so a BIG_OBJECT -- fills its whole 2x2 blob for collision and for talking alike, and the -- Vermilion Snorlax can be woken from any cell adjacent to any of its four. -- NPC:covers is WillObjectIntersectBigObject; every ordinary object answers it -- with the plain one-cell compare. function World:npcAt(cx, cy) for _, npc in ipairs(self.npcs) do if NPC.covers(npc, cx, cy) then return npc end end return nil end -- Gen 1's spelling (src/world/OverworldController.lua), for the reason the -- Map vocabulary aliases exist: a mod holds one name. function World:npcAtCell(cx, cy) return self:npcAt(cx, cy) end -- CheckIfFacingTileCoordIsBGEvent (home/map.asm) matches on the COORDINATES -- alone and leaves the function byte to BGEventJumptable; this narrows to -- BGEVENT_READ, the one arm that is a plain script pointer. BGEVENT_ITEM is -- src/world/gen2/HiddenItems.lua, off the same cell. -- BGEventJumptable, ported past its first arm. -- -- This matched `kind == 0` (BGEVENT_READ) only, which silently dropped five of -- the nine kinds. The one that mattered is BGEVENT_IFNOTSET: TeamRocketBaseB3F's -- locked door is two of them, so pressing A at Giovanni's door did nothing at -- all, EVENT_OPENED_DOOR_TO_GIOVANNIS_OFFICE could never be set, and the Rocket -- hideout dead-ended one room short of its boss. -- -- 0 READ a plain script pointer -- 1-4 UP/DOWN/RIGHT/LEFT .checkdir -- read only when facing that way -- 5 IFSET run the script only while the event IS set -- 6 IFNOTSET run it only while the event is NOT set -- 7 ITEM hidden item, handled by src/world/gen2/HiddenItems.lua -- 8 COPY copies data without reading; nothing to run -- -- The cart's facing test is `and %1100`, i.e. the direction's top two bits, so -- it compares the FACING and not the button that produced it. local BGEVENT_FACING = { [1] = "up", [2] = "down", [3] = "right", [4] = "left" } function World:bgEventAt(cx, cy) for _, ev in ipairs(self.map.def.bgEvents or {}) do if ev.x == cx and ev.y == cy then local kind = ev.kind or 0 if kind == 0 then return ev elseif BGEVENT_FACING[kind] then if self.player and self.player.facing == BGEVENT_FACING[kind] then return ev end elseif kind == 5 or kind == 6 then -- CheckBGEventFlag, then `.ifset` reads when set and `.ifnotset` when -- clear. An extraction that predates the conditional_event fix has no -- `event` field; refusing to guess is better than running the door -- script unconditionally. if ev.event and ev.scriptKey then local set = self.events and self.events:get(ev.event) or false if (kind == 5) == (set and true or false) then return ev end end end end end return nil end -- ---- trainers ------------------------------------------------------------- -- engine/events/trainer_scripts.asm, as inline command lists: nothing in the -- ROM points at these two, so the extractor never sees them and the VM has to -- be handed the list. Kept command-for-command so the ordering (music before -- the seen text, flag set after the battle, after-script as a tail call) stays -- checkable against the source. local SEEN_BY_TRAINER_SCRIPT = { { op = "loadtemptrainer" }, { op = "encountermusic" }, -- showemote EMOTE_SHOCK, LAST_TALKED, 30 { op = "showemote", emote = 0, object = -2, frames = 30 }, { op = "trainerapproach" }, { op = "faceobject", a = 0, b = 1 }, { op = "opentext" }, { op = "trainertext", index = 0 }, -- TRAINERTEXT_SEEN { op = "waitbutton" }, { op = "closetext" }, { op = "loadtemptrainer" }, { op = "startbattle" }, { op = "reloadmapafterbattle" }, { op = "trainerflagaction", action = 1 }, -- SET_FLAG { op = "scripttalkafter" }, } -- TalkToTrainerScript: an already-beaten trainer skips straight to its -- after-battle script, which is why a beaten rival still has a line. local TALK_TO_TRAINER_SCRIPT = { { op = "faceplayer" }, { op = "trainerflagaction", action = 2 }, -- CHECK_FLAG { op = "iftrue", script = { { op = "scripttalkafter" } } }, { op = "loadtemptrainer" }, { op = "encountermusic" }, { op = "opentext" }, { op = "trainertext", index = 0 }, { op = "waitbutton" }, { op = "closetext" }, { op = "loadtemptrainer" }, { op = "startbattle" }, { op = "reloadmapafterbattle" }, { op = "trainerflagaction", action = 1 }, { op = "scripttalkafter" }, } function World:trainerBeaten(record) if not (record and record.event) then return false end return self.events:get(record.event) and true or false end -- _CheckTrainerBattle: every visible, unbeaten trainer object that is facing -- the player along a shared row or column, within its own sight range. function World:checkTrainerBattle() if self:busy() or not self.player or not self.vm then return false end if self.player.moving then return false end local save = self.game and self.game.save if not (save and save.party and #save.party > 0) then return false end for _, npc in ipairs(self.npcs) do local record = npc.def and npc.def.trainer if record and not self:trainerBeaten(record) then local distance, dir = Trainers.sees(npc, self.player, npc.def.sight) if distance then return self:startTrainerScript(npc, SEEN_BY_TRAINER_SCRIPT, { distance = distance, dir = dir, }) end end end return false end -- Both entries to a map trainer -- the sight cone and an A press on one -- come -- through here, which is why world.trainer_engaged sits at this seam rather -- than at checkTrainerBattle. Gen 1's three payload keys are unchanged; -- trainerClass and partyIndex hold the `trainer` struct's numeric class -- constant and member number (macros/scripts/maps.asm) where Gen 1 holds its -- own class name and party index -- same role, same key. `trainerEvent` and -- `sight` are Gen 2 additions: the beaten-flag the struct carries, and the -- distance/direction when it was the eyesight test that engaged. function World:startTrainerScript(npc, script, sight) local record = npc and npc.def and npc.def.trainer if record and Runtime.wants("world.trainer_engaged") then Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = record.class, partyIndex = record.member, trainerEvent = record.event, sight = sight, }) end self.talkNpc = npc self:freezeNpc(npc) self.vm.trainerObject = npc.def.trainer self.trainerSight = sight self.trainerNpc = npc return self.vm:start(script) end -- The engaged / talked-to object holds still for the whole conversation. On -- the cart the seen-by script's own movement freezes everything -- (Script_applymovement -> FreezeAllOtherObjects, engine/overworld/ -- scripting.asm), and a talked-to wanderer stops because its struct is the -- script's LAST_TALKED; either way a SPINRANDOM trainer must not keep rolling -- new facings under his own sighting text. World:step unfreezes the pool the -- frame the interaction is over, which is EndScript's UnfreezeAllObjects. function World:freezeNpc(npc) if npc then npc.frozen = true end self.frozeNpcs = true end -- TrainerWalkToPlayer: close to one cell short of the player, then hand the -- VM back control. A trainer spotted from one cell away never moves. function World:trainerApproach(onDone) local sight = self.trainerSight local npc = self.trainerNpc if not (sight and npc) then if onDone then onDone() end return end local steps = Trainers.approach(sight.distance, sight.dir) if #steps == 0 then if onDone then onDone() end return end local bytes = {} for _, dir in ipairs(steps) do bytes[#bytes + 1] = Movement.stepByte(dir) end bytes[#bytes + 1] = Movement.STEP_END self:beginMovement(npc.def.index + 1, bytes, onDone) end -- TileCollisionStdScripts (data/collision/collision_stdscripts.asm), -- verbatim: collision byte -> the StdScripts label the extractor resolved -- into std_scripts.lua (RomExtractorGen2:extractStdScripts). This is how -- every Pokecenter PC, house radio, town map poster and bookshelf in the -- game is read -- none of them is a bg event. local TILE_COLLISION_STD_SCRIPTS = { [0x91] = "MagazineBookshelfScript", -- COLL_BOOKSHELF [0x93] = "PCScript", -- COLL_PC [0x94] = "Radio1Script", -- COLL_RADIO [0x95] = "TownMapScript", -- COLL_TOWN_MAP [0x96] = "MerchandiseShelfScript", -- COLL_MART_SHELF [0x97] = "TVScript", -- COLL_TV [0x9d] = "WindowScript", -- COLL_WINDOW [0x9f] = "IncenseBurnerScript", -- COLL_INCENSE_BURNER } -- what the A press resolved to, for world.interacted's listeners. Same four -- payload keys as Gen 1 and the same `kind` vocabulary where the two engines -- share an arm: "npc", "sign", "hidden", "script", "none". Gold's arms that -- Gen 1 has no equivalent of get their own words -- "trainer", "boulder", -- "itemball", "std" (the TileCollisionStdScripts table: PCs, radios, TVs, -- bookshelves) and "fieldmove" -- rather than being folded into one of Gen 1's. local function interacted(self, fx, fy, kind, target) if not Runtime.wants("world.interacted") then return end Runtime.emit("world.interacted", { mapId = self.map and self.map.id, x = fx, y = fy, kind = kind, target = target }) end -- A-press: talk to facing NPC, read a sign (BGEVENT_READ) or dig up a hidden -- item (BGEVENT_ITEM). -- -- Split so a replaced `OverworldController.interact` sits in front of the -- body (src/mods/Gen2Compat.lua); unpatched, this is one comparison. function World:interact() local wrapped = Gen1Facade.interactWrapper() if wrapped then return wrapped(self) end return self:interactBody() 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) -- 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 -- command, so SmashRockScript's `disappear LAST_TALKED` hid whichever -- object some earlier conversation had named -- the Burned Tower rock -- played its whole smash and stayed standing in any session where anybody -- had been talked to first. Object consts are index + 1, the same mapping -- disappearObject decodes. if npc and npc.def and self.vm then self.vm.lastTalked = (npc.def.index or 0) + 1 end -- The Gen 1 dispatch a follower mod wraps is OverworldState:talkTo(npc) -- (src/world/OverworldController.lua:2564), which has no single Gen 2 -- method: Gold dispatches inline from here. Same shape as -- interactWrapper -- nil unless something replaced it, and a true return -- suppresses the built-in path. if npc then local talkTo = Gen1Facade.talkToWrapper() if talkTo and talkTo(self, npc) then return true end end if npc and npc.def and npc.def.trainer then interacted(self, fx, fy, "trainer", npc) return self:startTrainerScript(npc, TALK_TO_TRAINER_SCRIPT, nil) end -- Any other object an A press lands on holds still too (see freezeNpc). if npc then self:freezeNpc(npc) end -- Every strength boulder in the game carries the same script -- `jumpstd -- StrengthBoulderScript`, which is a bare `farsjump AskStrengthScript` into -- ASM. There is no bytecode behind that label for the extractor to have -- found, so the boulder arm is handled here rather than through the VM, -- which would otherwise walk into a script key with nothing on the far side. if npc and World.isStrengthBoulder(npc) then self.talkNpc = npc interacted(self, fx, fy, "boulder", npc) return self:tryStrengthOW() end -- OBJECTTYPE_ITEMBALL: no scriptKey to start -- the object's pointer is the -- raw (item, quantity) pair, read by the extractor into `itemball`. Every -- plain Poke Ball on the floor comes through here; without this arm the -- press found the object, matched no branch, and fell through to the tile -- events -- no ball in the game could be picked up. if npc and npc.def and npc.def.itemball then self.talkNpc = npc interacted(self, fx, fy, "itemball", npc) return self.vm:start(HiddenItems.ballPickupScript( npc.def.itemball.item, npc.def.itemball.quantity, (npc.def.index or 0) + 1, -- The same resolver the itemfinder gets: the script names its sfx by -- pokegold LABEL and this looks it up in THIS cache's sfx table, rather -- than trusting a numeric id that only holds for the shipped Gold cache. function(want, id) return self:sfxIdNamed(want, id) end)) end if npc and npc.def and npc.def.scriptKey then self.talkNpc = npc interacted(self, fx, fy, "npc", npc) return self.vm:start(npc.def.scriptKey) end local sign = self:bgEventAt(fx, fy) if sign and sign.scriptKey then self.talkNpc = nil interacted(self, fx, fy, "sign", sign) return self.vm:start(sign.scriptKey) end -- BGEVENT_ITEM, the `.itemifset` arm of the same jumptable: a hidden item. -- Its operand is `hiddenitem` data rather than a script, so there is no -- scriptKey for the arm above to have found and the list is built here. An -- item already taken has its flag set, and `.itemifset` jumps to `.dontread` -- on that -- no carry, so the press falls through to the tile events below -- exactly as if the bg event were not there. local hidden = HiddenItems.at(self.map and self.map.def, fx, fy, self.events) if hidden then self.talkNpc = nil -- PlayTalkObject, the SFX every read of a bg event opens on. self:playSfxNamed("Sfx_ReadText2", SFX_READ_TEXT_2) interacted(self, fx, fy, "hidden", hidden) return self.vm:start(HiddenItems.pickupScript(hidden.item, hidden.event)) end -- CheckAPressOW's third and last try (engine/overworld/events.asm): -- TryObjectEvent, then TryBGEvent, then TryTileCollisionEvent -- the facing -- TILE's own events. TryTileCollisionEvent's own order is fixed and load -- bearing: CheckFacingTileForStdScript first, then cut tree, whirlpool, -- waterfall, headbutt tree, and SURF last as the catch-all. Each arm -- claims the press only if the facing tile matches its collision, which is -- what keeps a tree from stealing a sign's press and what makes SURF the -- one arm allowed to be silent. -- -- CheckFacingTileForStdScript (engine/events/std_collision.asm): a facing -- collision with a TileCollisionStdScripts row runs that std script and -- returns carry. The bodies come out of the cache's std_scripts table and -- run through the VM like any map script -- PCScript is `opentext / -- special PokemonCenterPC / closetext / end`. local std = TILE_COLLISION_STD_SCRIPTS[ self.map and self.map:cellCollision(fx, fy)] if std then local entry = self.stdScripts and self.stdScripts.scripts and self.stdScripts.scripts[std] if entry and entry.key then self.talkNpc = nil interacted(self, fx, fy, "std", std) return self.vm:start(entry.key) end end if self:tryCutOW() then interacted(self, fx, fy, "fieldmove", "CUT") return true end if self:tryWhirlpoolOW() then interacted(self, fx, fy, "fieldmove", "WHIRLPOOL") return true end if self:tryWaterfallOW() then interacted(self, fx, fy, "fieldmove", "WATERFALL") return true end if self:tryHeadbuttOW(fx, fy) then interacted(self, fx, fy, "fieldmove", "HEADBUTT") return true end if self:trySurfOW() then interacted(self, fx, fy, "fieldmove", "SURF") return true end interacted(self, fx, fy, "none") return false end function World:fitScale() local w, h = love.graphics.getDimensions() return math.max(1, math.floor(math.min(w / 160, h / 144))) end function World:zoomScale() return Zoom.scale(self:fitScale()) end -- Outdoor Johto/Kanto town tilesets are the only ones that swap in roof -- tiles $0a-$12 (mapgroup_roofs.asm). Applying roofs to indoor tilesets -- in the same map group (lab, houses) corrupts their GFX. local ROOF_TILESETS = { TILESET_JOHTO = true, TILESET_JOHTO_MODERN = true, TILESET_KANTO = true, } function World:atlasFor(mapDef) local tileset = self.tilesets[mapDef.tileset] if not tileset then return nil end local cacheKey = mapDef.tileset local roofName = nil if ROOF_TILESETS[mapDef.tileset] then roofName = self.roofs and self.roofs.mapGroupRoofs and self.roofs.mapGroupRoofs[mapDef.group] end if roofName then cacheKey = cacheKey .. "|" .. roofName end local cached = self.atlasCache[cacheKey] if cached then return cached, tileset end local tilesPerRow = tileset.tilesPerRow or 16 local atlas local roofSpec = roofName and self.roofs.roofs and self.roofs.roofs[roofName] if roofSpec and roofSpec.image then local ok, img = pcall(applyRoofOverlay, tileset.image, roofSpec.image, tilesPerRow) if ok then atlas = img end end if not atlas then local ok, img = pcall(Assets.image, tileset.image) if not ok then return nil, tileset end atlas = img atlas:setFilter("nearest", "nearest") end self.atlasCache[cacheKey] = atlas return atlas, tileset end -- Bakes one map's 32px blocks into a single canvas. -- -- With palettes available this is a GBC-accurate render, not a grayscale one: -- a tile's four colors come from its tileset PalMap slot inside the eight BG -- palettes LoadMapPals would have loaded for this map's environment, time of -- day and map group. The bake walks slot 0..7 and draws only the tiles that -- belong to each, so the shader's palette uniform changes eight times per map -- instead of once per tile -- tiles never overlap, so the pass order is free. -- -- `daytime` is baked in, which is why mapImages is keyed by map *and* daytime: -- a night-to-morning rollover re-bakes rather than tinting, exactly like the -- cart reloading wBGPals1. function World:bakeMapImage(map, daytime, flicker) local atlas, tileset = self:atlasFor(map.def) if not atlas or not tileset then return nil end local blocks = tileset.blocks local tilesPerRow = tileset.tilesPerRow or 16 local pw, ph = map.width * 32, map.height * 32 local canvas = love.graphics.newCanvas(pw, ph) canvas:setFilter("nearest", "nearest") local quads = {} local function quadFor(tile) local q = quads[tile] if q then return q end local sx = (tile % tilesPerRow) * 8 local sy = math.floor(tile / tilesPerRow) * 8 q = love.graphics.newQuad(sx, sy, 8, 8, atlas:getDimensions()) quads[tile] = q return q end local tilePalettes = tileset.tilePalettes local bgSet = self.palettes and daytime and Palettes.bgSet(self.palettes, map.def, daytime) or nil -- FlickeringCaveEntrancePalette: on a DARKNESS_PALSET map, PAL_BG_YELLOW's -- color 0 is rewritten every VBlank from either its own color 0 or its -- color 1. In the dark palette row those are RGB 30,30,11 and black, so -- the cave entrance blinks and nothing else in the map does. Both phases -- are baked and cached, and World:pollCaveFlicker swaps between them -- -- flipping a whole canvas is what a palette write costs here. if bgSet and daytime == "DARK" then bgSet = Palettes.withCaveFlicker(bgSet, flicker or 1) end local colored = bgSet and tilePalettes and GbcPalette.available() -- Slot 0 color 0 is the map's background wash; falling back to a flat green -- only matters for a pre-palette cache. local clearColor = { 0.15, 0.55, 0.25 } if bgSet and bgSet[1] and bgSet[1][1] then -- Through GbcPalette.color so the COLOR option reaches the wash too: it -- is a palette colour drawn as a plain fill, so nothing else would -- substitute it and a DMG-mode map would sit on a green field. local c = GbcPalette.color(bgSet[1], 1) clearColor = { c[1] / 255, c[2] / 255, c[3] / 255 } end local function drawTiles(slot) for by = 0, map.height - 1 do for bx = 0, map.width - 1 do -- LoadMetatiles reads block id 0 as the map header's border block, -- not as tileset block 0 (see src/world/gen2/BorderFill.lua), so a -- hole in the block list paints the same wall the margin does. local blockId = BorderFill.blockFor( map.blocks[by * map.width + bx + 1], map.borderBlock) local block = blocks and blocks[(blockId or 0) + 1] if block then for i = 0, 15 do local tile = block[i + 1] or 0 -- tilePalettes is 1-based over the 96 sheet tiles; anything past -- the sheet (window/text tiles) has no entry and takes slot 1. local tileSlot = tilePalettes and tilePalettes[tile + 1] or 1 if not slot or tileSlot == slot then local tx = bx * 32 + (i % 4) * 8 local ty = by * 32 + math.floor(i / 4) * 8 love.graphics.draw(atlas, quadFor(tile), tx, ty) end end end end end end canvas:renderTo(function() love.graphics.clear(clearColor[1], clearColor[2], clearColor[3], 1) love.graphics.setColor(1, 1, 1, 1) -- A LOVE canvas does not reset the transform, and this bake is NOT only -- reached from the fixed step: World:refreshColorMode runs at the top of -- World:draw, so the first bake after the OPTION screen changes COLOR -- happens under whatever scale and offset the Renderer had already pushed -- for the world canvas. Without the origin the whole map is baked at that -- transform and then CACHED under its mapImages key, so one mis-timed bake -- keeps a wrong canvas until the time of day rolls over. Same guard as -- World:drawTilted below and BattleAnimView's panel. love.graphics.push() love.graphics.origin() if colored then for slot = 1, 8 do GbcPalette.with(bgSet[slot], function() drawTiles(slot) end) end else drawTiles(nil) end love.graphics.pop() end) return canvas, bgSet end -- The functions whose frame strip the extractor already resolved, keyed to the -- rule World:animRow reads them back with. local ANIM_KINDS = { AnimateWhirlpoolTile = "whirlpool", AnimateTowerPillarTile = "tower", AnimateLavaBubbleTile1 = "lava1", AnimateLavaBubbleTile2 = "lava2", } -- Every VRAM tile a tileset's program rewrites, and where each one's frames -- come from (engine/tilesets/tileset_anims.asm:167 water, :197 flower, :231 -- and :259 lava, :290 tower pillar, :350 whirlpool, plus the buffer scrolls -- at :65 and :139). function World:animLayers(tileset) local anim = tileset and tileset.anim if not (anim and anim.frames) then return nil end local defs = self.tilesets local wanted = nil local function add(tile, layer) -- A cache built before the strips were extracted has no sheet to draw -- from, so that tile is left to the bake. if not tile or (layer.kind ~= "scroll" and not layer.sheet) then return end wanted = wanted or {} wanted[tile] = layer end for _, frame in ipairs(anim.frames) do local func = frame.func if func == "AnimateWaterTile" then add(frame.tile, { kind = "water", sheet = defs and defs.waterFrames, frames = 4 }) elseif func == "AnimateFlowerTile" then -- AnimateFlowerTile takes no argument: it hardcodes vTiles2 tile $03 -- (engine/tilesets/tileset_anims.asm:222). add(0x03, { kind = "flower", sheet = defs and defs.flowerFrames, frames = 4 }) elseif ANIM_KINDS[func] then add(frame.tile, { kind = ANIM_KINDS[func], sheet = frame.sheet, frames = frame.frames }) elseif func == "WriteTileFromAnimBuffer" and frame.scroll then add(frame.tile, { kind = "scroll", scroll = frame.scroll, frames = 8 }) end end return wanted end -- The cells a tileset's anim program repaints, gathered once per bake and -- keyed by tile id: the map canvas is baked and never rewritten, so the frames -- _AnimateTileset would have written into VRAM are drawn over it instead. function World:animCellsFor(map, tileset) local wanted = self:animLayers(tileset) if not wanted then return nil end local blocks = tileset.blocks local tilePalettes = tileset.tilePalettes local out = nil for by = 0, map.height - 1 do for bx = 0, map.width - 1 do local blockId = BorderFill.blockFor( map.blocks[by * map.width + bx + 1], map.borderBlock) local block = blocks and blocks[(blockId or 0) + 1] if block then for i = 0, 15 do local tile = block[i + 1] or 0 local layer = wanted[tile] if layer then out = out or {} local list = out[tile] if not list then list = { layer = layer, tile = tile, slot = tilePalettes and tilePalettes[tile + 1] or 1, cells = {}, } out[tile] = list end local cells = list.cells cells[#cells + 1] = bx * 32 + (i % 4) * 8 cells[#cells + 1] = by * 32 + math.floor(i / 4) * 8 end end end end end return out end -- The key imageFor caches a map's bake under, shared with the anim cell lists -- and the palettes so the draw pass can find them from a map id alone. function World:mapCacheKey(mapId) local daytime = self.daytime local flicker = (daytime == "DARK") and self.flickerPhase or 1 return mapId .. "|" .. tostring(daytime) .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker) end function World:imageFor(mapId) local daytime = self.daytime -- The COLOR mode is baked in alongside the daytime -- the palettes go on -- when the map canvas is drawn, not when it is sampled -- so it belongs in -- the key. Keying by it rather than flushing on a mode change also means -- switching back and forth costs nothing after the first bake of each. -- ...and so is the cave-entrance flicker phase, for the same reason: a dark -- map has two bakes, not one, and they differ by a single palette colour. local flicker = (daytime == "DARK") and self.flickerPhase or 1 local cacheKey = self:mapCacheKey(mapId) local cached = self.mapImages[cacheKey] if cached then return cached end local def = self.maps[mapId] if not def then return nil end local tileset = self.tilesets[def.tileset] if not tileset then return nil end local map = Map.new(def, tileset) local img, bgSet = self:bakeMapImage(map, daytime, flicker) self.mapImages[cacheKey] = img -- Under the same key as the bake: the overlay needs both the cell list and -- the palettes the bake resolved. self.animCells[cacheKey] = self:animCellsFor(map, tileset) or false self.bgSets[cacheKey] = bgSet or false return img end -- SetTallGrassFlags' own test (engine/overworld/map_objects.asm:247): -- CheckSuperTallGrassTile first, then CheckGrassTile. function World:grassAt(cx, cy) local map = self.map if not map then return false end local coll = map:cellCollision(cx, cy) return Permissions.isSuperTallGrass(coll) or Permissions.isGrass(coll) end -- The tileset atlas with BG colour 0 keyed to alpha. The grass tile is about -- two-fifths colour 0 and those pixels are the gaps the legs show through, so -- the redraw over a sprite needs the key -- same `r > 0.83` rule -- src/render/SpriteRenderer.lua uses on OBJ sheets. function World:grassAtlasFor(mapDef) local tileset = self.tilesets and self.tilesets[mapDef and mapDef.tileset] if not (tileset and tileset.image) then return nil end self.grassAtlases = self.grassAtlases or {} local cached = self.grassAtlases[tileset.image] if cached ~= nil then return cached or nil, tileset end local made = false if love.image and love.image.newImageData then local ok, data = pcall(Assets.imageData, tileset.image) if ok and data and data.mapPixel then data:mapPixel(function(_, _, r, g, b, a) if r > 0.83 then return r, g, b, 0 end return r, g, b, a end) local okImg, img = pcall(love.graphics.newImage, data) if okImg and img then img:setFilter("nearest", "nearest") made = img end end end self.grassAtlases[tileset.image] = made return made or nil, tileset end -- The 8x8 BG tile at a map pixel, through LoadMetatiles' border-block rule. function World:bgTileAt(map, tileset, mx, my) local bx, by = math.floor(mx / 32), math.floor(my / 32) if bx < 0 or by < 0 or bx >= map.width or by >= map.height then return nil end local blockId = BorderFill.blockFor( map.blocks[by * map.width + bx + 1], map.borderBlock) local block = tileset.blocks and tileset.blocks[(blockId or 0) + 1] if not block then return nil end local i = math.floor((my % 32) / 8) * 4 + math.floor((mx % 32) / 8) return block[i + 1] end -- IN_GRASS puts OAM_PRIO on the sprite's lower 16x8 only: .InitSprite ORs it -- into hCurSpriteOAMFlags (engine/overworld/map_objects.asm:2850) and only the -- bottom two OAM entries of a walking facing carry RELATIVE_ATTRIBUTES -- (data/sprites/facings.asm:45-56). The strip starts at py+4 because a sprite -- draws 4 px above its cell (map_objects.asm:2876). function World:drawGrassOver(entity, ox, oy, s) local map = self.map if not (entity and entity.inGrass and map) then return end local atlas, tileset = self:grassAtlasFor(map.def) if not (atlas and tileset) then return end local G = love.graphics local bgSet = self.bgSets[self:mapCacheKey(map.id)] or nil local tilePalettes = tileset.tilePalettes local tilesPerRow = tileset.tilesPerRow or 16 local aw, ah = atlas:getDimensions() local rx, ry = entity.px, entity.py + 4 self.grassQuad = self.grassQuad or G.newQuad(0, 0, 8, 8, aw, ah) local quad = self.grassQuad G.setColor(1, 1, 1, 1) for ty = math.floor(ry / 8) * 8, math.floor((ry + 7) / 8) * 8, 8 do for tx = math.floor(rx / 8) * 8, math.floor((rx + 15) / 8) * 8, 8 do local tile = self:bgTileAt(map, tileset, tx, ty) if tile then local cx0, cy0 = math.max(rx, tx), math.max(ry, ty) local cx1 = math.min(rx + 16, tx + 8) local cy1 = math.min(ry + 8, ty + 8) if cx1 > cx0 and cy1 > cy0 then quad:setViewport( (tile % tilesPerRow) * 8 + (cx0 - tx), math.floor(tile / tilesPerRow) * 8 + (cy0 - ty), cx1 - cx0, cy1 - cy0, aw, ah) local set = bgSet and bgSet[tilePalettes and tilePalettes[tile + 1] or 1] local function blit() G.draw(atlas, quad, math.floor(ox + cx0 * s), math.floor(oy + cy0 * s), 0, s, s) end if set and GbcPalette.available() then GbcPalette.with(set, blit) else blit() end end end end end end -- ShakeGrass' object (engine/overworld/map_objects.asm:2031): FacingGrass1 is -- the tile at (0,+8) and (+8,+8) from the sprite's origin, FacingGrass2 at -- (-1,+9) and (+9,+9), and SetFacingGrassShake's `and 4` alternates them every -- four frames (data/sprites/facings.asm:230-239, map_object_action.asm:257). function World:drawGrassShake(entity, ox, oy, s) local sheet = self.grassRustleImage if not (sheet and entity and entity.grassShake and entity.moving) then return end local frames = entity.stepFrames or Player.STEP_FRAMES local progress = entity.progress or 0 -- MovementFunction_ShakingGrass takes the tracked object's STEP_DURATION -- minus one (map_objects.asm:965), so it dies a frame before the step lands. if progress >= frames then return end local G = love.graphics local x1, x2, dy = 0, 8, 4 if progress % 8 >= 4 then x1, x2, dy = -1, 9, 5 end local colors = Palettes.spritePalette(self.palettes, self.daytime or Palettes.daytimeFor(self.map and self.map.def, self:hour(), self.flashUsed), { paletteId = 6 }) local function blit() G.setColor(1, 1, 1, 1) local y = math.floor(oy + (entity.py + dy) * s) G.draw(sheet, math.floor(ox + (entity.px + x1) * s), y, 0, s, s) -- OAM_XFLIP on the second entry of both facings, so it draws right to left. G.draw(sheet, math.floor(ox + (entity.px + x2 + 8) * s), y, 0, -s, s) end if colors and GbcPalette.available() then GbcPalette.with(colors, blit) else blit() end end -- This frame's AnimateWaterTile graphic for a map's border block, or nil when -- the block holds no water at all (engine/tilesets/tileset_anims.asm:167). function World:borderWaterFrame(def, tileset) local anim = tileset and tileset.anim if not (anim and anim.frames) then return nil end local tile for _, frame in ipairs(anim.frames) do if frame.func == "AnimateWaterTile" and frame.tile then tile = frame.tile break end end if not tile then return nil end local block = tileset.blocks and tileset.blocks[BorderFill.blockFor(0, def.borderBlock) + 1] if not block then return nil end local found = false for i = 1, 16 do if block[i] == tile then found = true break end end if not found then return nil end local sheets = self:animSheets() if not (sheets and sheets.water) then return nil end return { image = sheets.water, row = World.waterFrameFor(self.animTimer), tile = tile, slot = tileset.tilePalettes and tileset.tilePalettes[tile + 1] or 1, } end -- The 32x32 border-block bake for a map, cached under the same key its canvas -- is (plus a suffix), so the daytime rollover, the COLOR option, the flicker -- phase and World:dropMapImages' prefix sweep all reach it. The palettes are -- the map's own, because the border block comes out of the map's tileset and -- LoadMapPals colours it with everything else on screen. function World:borderImageFor(mapId) local daytime = self.daytime local flicker = (daytime == "DARK") and self.flickerPhase or 1 local def = self.maps and self.maps[mapId] if not def then return nil end local tileset = self.tilesets and self.tilesets[def.tileset] if not tileset then return nil end -- A border block made of water animates with the rest of the map, so this -- frame's row joins the key: four bakes per map instead of one. local waterFrame = self:borderWaterFrame(def, tileset) local cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime) .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker) .. "|" .. tostring(waterFrame and waterFrame.row or 0)) local cached = self.mapImages[cacheKey] if cached ~= nil then return cached or nil end local atlas = self:atlasFor(def) if not atlas then return nil end local bgSet = self.palettes and daytime and Palettes.bgSet(self.palettes, def, daytime) or nil if bgSet and daytime == "DARK" then bgSet = Palettes.withCaveFlicker(bgSet, flicker or 1) end local ok, img = pcall(BorderFill.bake, atlas, tileset, BorderFill.blockFor(0, def.borderBlock), bgSet, waterFrame) -- `false` rather than nil: a bake that cannot be made (a headless run with -- no canvas support) must not be retried once per frame forever. self.mapImages[cacheKey] = (ok and img) or false return (ok and img) or nil end -- FlickeringCaveEntrancePalette runs on the VBlank clock, so this runs on the -- fixed step: two frames on, two frames off, and only on a DARKNESS_PALSET -- map. A phase change swaps the baked canvas rather than re-baking -- both -- phases stay in mapImages once they have been drawn once. function World:pollCaveFlicker() if self.daytime ~= "DARK" or not self.mapImage then return false end self.flickerClock = (self.flickerClock + 1) % Palettes.FLICKER_PERIOD local phase = Palettes.caveFlickerSource(self.flickerClock) if phase == self.flickerPhase then return false end self.flickerPhase = phase self.mapImage = self:imageFor(self.map.id) for _, nb in ipairs(self.neighbors) do nb.image = self:imageFor(nb.id) or nb.image end return true end -- `wTileAnimationTimer and %110` (engine/tilesets/tileset_anims.asm:172-174): -- four water frames, each held for two ticks of the 0..7 timer. 1-based, so -- it indexes the four rows of the water sheet directly. function World.waterFrameFor(timer) return math.floor(((timer or 0) % 8) / 2) + 1 end -- AnimateFlowerTile's `and %10` plus hCGB (tileset_anims.asm:204-212): on CGB -- only cgb_1 and cgb_2 are ever written, alternating every two timer ticks. function World:flowerFrameFor(timer) local rows = self.tilesets and self.tilesets.flowerCgbFrames local first, second = 2, 4 if rows and rows[1] and rows[2] then first, second = rows[1], rows[2] end return ((timer or 0) % 4 < 2) and first or second end -- _AnimateTileset (engine/tilesets/tileset_anims.asm:11) runs ONE row of the -- tileset's program per frame and DoneTileAnimation (:48) wraps the index, so -- a whole pass is `period` frames; StandingTileFrame8 (:57) ticks the 0..7 -- timer once per pass. Same VBlank clock the cave flicker runs on. function World:pollTileAnim() local tileset = self.map and self.map.def and self.tilesets and self.tilesets[self.map.def.tileset] local anim = tileset and tileset.anim if not (anim and anim.period and anim.period > 0) then return false end self.animClock = (self.animClock or 0) + 1 if self.animClock < anim.period then return false end self.animClock = 0 self.animTimer = ((self.animTimer or 0) + 1) % 8 return true end -- AnimateTowerPillarTile's own offsets table (tileset_anims.asm:334-342), -- 1-based: five frames walked up and back down over the 0..7 timer. local TOWER_ROWS = { [0] = 1, 2, 3, 4, 5, 4, 3, 2 } -- Which row of a layer's strip this timer value shows. Whirlpool `and %11` -- (tileset_anims.asm:368); lava `and %110` halved, with tile $5b running two -- frames ahead of tile $38 (:238-245, :266-270). function World:animRow(layer) local timer = (self.animTimer or 0) % 8 local kind = layer.kind if kind == "water" then return World.waterFrameFor(timer) end if kind == "flower" then return self:flowerFrameFor(timer) end if kind == "whirlpool" then return (timer % 4) + 1 end if kind == "tower" then return TOWER_ROWS[timer] end if kind == "lava2" then return math.floor(timer / 2) + 1 end if kind == "lava1" then return ((math.floor(timer / 2) + 2) % 4) + 1 end -- A scroll strip is baked one row per timer value. return timer + 1 end -- One frame strip, loaded once. `false` after a miss so a cache from before -- it was extracted costs one pcall rather than one a frame. function World:animSheet(path) if not path then return nil end self.animSheetPaths = self.animSheetPaths or {} local cached = self.animSheetPaths[path] if cached ~= nil then return cached or nil end local ok, img = pcall(Assets.image, path) if ok and img then img:setFilter("nearest", "nearest") end self.animSheetPaths[path] = (ok and img) or false return (ok and img) or nil end -- The two strips the border-block bake reaches for by name -- (assets/generated/tilesets/*_frames.png), memoized as a pair. function World:animSheets() if self.animSheetCache ~= nil then return self.animSheetCache or nil end local defs = self.tilesets local sheets = nil for kind, path in pairs({ water = defs and defs.waterFrames, flower = defs and defs.flowerFrames }) do local img = self:animSheet(path) if img then sheets = sheets or {} sheets[kind] = img end end self.animSheetCache = sheets or false return sheets end -- ScrollTileRightLeft (tileset_anims.asm:65) scrolls right for four ticks then -- left for four, one call per pass in every program that uses it: the offset -- the buffer has reached at each value of the 0..7 timer. local SCROLL_H = { [0] = 0, 1, 2, 3, 2, 1, 0, -1 } -- One tile's eight scroll positions stacked into an 8x64 strip. The cart -- rotates the tile in VRAM (ScrollTileRightLeft, ScrollTileDown :139) and the -- map canvas is already baked, so the rotations are baked off the atlas once -- and drawn over it like any other frame strip. function World:scrollStrip(mapDef, tileset, tile, scroll) local key = tostring(mapDef.tileset) .. "|" .. tile .. "|" .. tostring(scroll.h) .. "," .. tostring(scroll.v) self.scrollStrips = self.scrollStrips or {} local cached = self.scrollStrips[key] if cached ~= nil then return cached or nil end local atlas = self:atlasFor(mapDef) local ok, canvas = pcall(love.graphics.newCanvas, 8, 8 * 8) if not (atlas and ok and canvas) then self.scrollStrips[key] = false return nil end canvas:setFilter("nearest", "nearest") local perRow = tileset.tilesPerRow or 16 local sx, sy = (tile % perRow) * 8, math.floor(tile / perRow) * 8 local aw, ah = atlas:getDimensions() local G = love.graphics local drawn = pcall(function() canvas:renderTo(function() -- Same origin guard the map bake takes: this can run under whatever -- transform World:draw had already pushed. G.push() G.origin() G.clear(0, 0, 0, 0) G.setColor(1, 1, 1, 1) for timer = 0, 7 do local dx = ((scroll.h or 0) > 0) and (SCROLL_H[timer] % 8) or 0 local dy = (timer * (scroll.v or 0)) % 8 -- The tile wraps, so each position draws as up to four pieces: -- { destination, source, size }. for _, col in ipairs({ { dx, 0, 8 - dx }, { 0, 8 - dx, dx } }) do for _, row in ipairs({ { dy, 0, 8 - dy }, { 0, 8 - dy, dy } }) do if col[3] > 0 and row[3] > 0 then G.draw(atlas, G.newQuad( sx + col[2], sy + row[2], col[3], row[3], aw, ah), col[1], timer * 8 + row[1]) end end end end G.pop() end) end) self.scrollStrips[key] = drawn and canvas or false return drawn and canvas or nil end -- One 8x8 row of a frame strip, reused: this runs every overworld frame and a -- fresh Quad per cell would churn the GC. function World:animQuad(key, row, frames) self.animQuads = self.animQuads or {} local id = key .. "#" .. row local q = self.animQuads[id] if not q then q = love.graphics.newQuad(0, (row - 1) * 8, 8, 8, 8, (frames or 4) * 8) self.animQuads[id] = q end return q end -- Draw this frame's animated tiles over one baked canvas. Culled to the -- view: a sea route names every water cell on the map and only a screenful of -- them is ever visible. function World:drawAnimCells(mapId, ox, oy, s) if not mapId then return end local key = self:mapCacheKey(mapId) local cells = self.animCells[key] if not cells then return end local def = self.maps and self.maps[mapId] local tileset = def and self.tilesets and self.tilesets[def.tileset] local G = love.graphics local cam = self.camera local bgSet = self.bgSets[key] or nil local left = cam.x - ox - 8 local top = cam.y - oy - 8 local right = left + (self.viewW or 160) + 16 local bottom = top + (self.viewH or 144) + 16 for _, list in pairs(cells) do local layer = list.layer local sheet if layer.kind == "scroll" then sheet = tileset and self:scrollStrip(def, tileset, list.tile, layer.scroll) else sheet = self:animSheet(layer.sheet) end if sheet then local row = self:animRow(layer) local quad = self:animQuad( layer.sheet or ("scroll|" .. list.tile), row, layer.frames) local xy = list.cells local function blit() G.setColor(1, 1, 1, 1) for i = 1, #xy, 2 do local tx, ty = xy[i], xy[i + 1] if tx >= left and tx <= right and ty >= top and ty <= bottom then G.draw(sheet, quad, math.floor((tx + ox - cam.x) * s), math.floor((ty + oy - cam.y) * s), 0, s, s) end end end -- All cells of one tile id share a PalMap slot, so the palette is set -- once per id rather than once per cell. local set = bgSet and bgSet[list.slot] if set and GbcPalette.available() then GbcPalette.with(set, blit) else blit() end end end end -- Recompute the active time of day and hand every drawable its colors. -- Called on map entry and once a second while walking, so a real-clock -- rollover repaints the world the way ReplaceTimeOfDayPals does on the cart. -- -- Returns true when the daytime actually changed, i.e. the baked map images -- are stale and callers must drop them. -- One entity's OW palette. SpriteRenderer bakes the sheet against these -- colours and keys OBJ colour 0 to alpha; the group string keeps one bake per -- (daytime, PAL_OW_*) pair, so two NPCs on the same palette share it. function World:applySpritePalette(entity) if not (self.palettes and entity and entity.sprite and entity.spriteDef) then return end local daytime = self.daytime or Palettes.daytimeFor(self.map and self.map.def, self:hour(), self.flashUsed) -- entity.def is the object_event, whose own palette field OVERRIDES the -- sprite's (Palettes.objectPaletteId; AddMapObject, player_object.asm:187). -- The player has no object_event here, so it falls through to the sheet. local colors = Palettes.spritePalette(self.palettes, daytime, entity.spriteDef, entity.def) if not colors then return end -- The bake cache key has to be the palette actually chosen, or the three -- beasts -- one sheet, three object palettes -- would all share the first -- bake taken. local id = Palettes.objectPaletteId(entity.def) or entity.spriteDef.paletteId or 0 entity.sprite:setObjPalette(colors, ("gen2:%s:%d"):format(tostring(daytime), id)) end -- the two vanilla links the time-of-day chains wrap, hoisted so an empty -- chain allocates no closure (src/world/OverworldController.lua does the same -- for the Gen 1 pair) local function sameTod(tod) return tod end local function samePalette(name) return name end -- world.tod, the same name and the same period strings Gen 1's -- OverworldState:timeOfDay wraps, and the same job: answer what time of day -- the WORLD is in. It carries more here because Gold has a real clock behind -- it (src/core/gen2/Clock.lua), so this is the one write everything downstream -- reads -- World:timeOfDayId's VAR_TIMEOFDAY, the encounter slots, the object -- hour windows and the palette bake all follow whatever comes back. -- -- Gen 1's ctx keys (map, mapId, x, y, steps) are kept verbatim; `hour` and -- `weekday` are added, because on Gold a day/night mod has a real hour to -- reason about instead of a step counter. function World:timeOfDay(hour) local clock = Palettes.clockDaytime(hour) if not Runtime.wantsHook("world.tod") then return clock end local map = self.map local p = self.player local save = self.game and self.game.save local next_ = Runtime.call("world.tod", sameTod, clock, { map = map, mapId = map and map.id, x = p and p.cellX, y = p and p.cellY, steps = (save and save.stepCount) or 0, hour = hour, weekday = self:weekday(), }) if type(next_) ~= "string" or next_ == "" then return clock end return next_ end function World:applyPalettes() if not self.map then return false end local previous = self.daytime local previousTod = self.tod -- GetTimeOfDay reads hHours, the one clock UpdateTime writes (home/time.asm): -- the palette, the object hour windows (World:objectTimeVisible), the -- day/night encounter slots and VAR_HOUR are all the same read, so this goes -- through World:hour rather than round-tripping the host clock inside -- Palettes.clockDaytime. local hour = self:hour() local tod = self:timeOfDay(hour) self.tod = tod -- ReplaceTimeOfDayPals.BrightnessLevels: a map header that pins a PALETTE_* -- overrides the clock outright, and PALETTE_DARK additionally becomes NITE -- once FLASH has been used. Only the maps that FOLLOW the clock take the -- hooked answer -- pinning is the map saying it does not care what hour it -- is, and a world.tod mod must not unpin Union Cave. local def = self.map.def local pinned = def and def.palette and def.palette ~= "PALETTE_AUTO" local daytime = pinned and Palettes.daytimeFor(def, hour, self.flashUsed) or tod -- map.palette, the same name Gen 1 wraps around a map's resolved palette -- name. Gold has no palette-name table: which four-colour set a map loads is -- named by its DAYTIME, so that is the value in the chain here. The -- arguments are Gen 1's -- value, map, ctx -- and ctx keeps `tod` and adds -- the two Gen 2 facts behind the answer. if Runtime.wantsHook("map.palette") then local hooked = Runtime.call("map.palette", samePalette, daytime, self.map, { tod = tod, environment = def and def.environment, pinned = pinned and def.palette or nil, hour = hour, flashUsed = self.flashUsed and true or false }) if type(hooked) == "string" and Palettes.DAYTIME_ID[hooked] then daytime = hooked end end self.daytime = daytime local changed = previous ~= self.daytime -- Gen 1 fires world.tod_changed off the same transition; `daytime` is the -- addition, because on Gold the period the world is in and the palette set a -- pinned map loads are not always the same string. if previousTod ~= nil and previousTod ~= tod and Runtime.wants("world.tod_changed") then Runtime.emit("world.tod_changed", { tod = tod, previous = previousTod, mapId = self.map.id, daytime = self.daytime, }) end if self.palettes then self:applySpritePalette(self.player) for _, npc in pairs(self.npcPool or {}) do self:applySpritePalette(npc) end end return changed end function World:rebuildNeighbors() self.neighbors = {} if not self.map then return end local s = self:zoomScale() local ww, wh = love.graphics.getDimensions() local vw = math.ceil(ww / s) local vh = math.ceil(wh / s) if vw % 2 ~= 0 then vw = vw + 1 end if vh % 2 ~= 0 then vh = vh + 1 end self.viewW, self.viewH = vw, vh local list = World.computeNeighbors( self.maps, self.map.id, NEIGHBOR_HOPS, vw, vh) for _, n in ipairs(list) do local img = self:imageFor(n.id) if img then table.insert(self.neighbors, { id = n.id, ox = n.ox, oy = n.oy, image = img }) end end end function World:setMap(mapId, cx, cy, facing, opts) opts = opts or {} local def = self.maps[mapId] if not def then self.status = "Unknown map " .. tostring(mapId) return false end local tileset = self.tilesets[def.tileset] if not tileset then self.status = "Missing tileset " .. tostring(def.tileset) return false end -- map.exited / map.entered are the Gen 1 pair (src/world/OverworldController -- setMap), same names and same payload keys. Divergence, deliberate: the -- exit is emitted BELOW the two guards above rather than at the top of the -- function, because Gold's setMap can refuse a load Gen 1's would have -- asserted on, and an "exited" that is followed by no "entered" reads to a -- listener as a map that vanished. local fromMapId = self.map and self.map.id if fromMapId then Runtime.emit("map.exited", { mapId = fromMapId, toMapId = mapId }) end -- LoadMapAttributes refills wOverworldMapBlocks from ROM, so every block CUT -- and WHIRLPOOL swapped out goes back: a cut tree is standing again the next -- time the map is loaded, and this has to happen before Map.new reads them. self:restoreBlocks() self:restoreObjectSpawns() -- HandleNewMap (home/map.asm:216-228) runs ResetMapBufferEventFlags before -- anything else that touches state: event flags 0-7 -- (EVENT_TEMPORARY_UNTIL_MAP_RELOAD) die on every map load, which is what -- lets Bill's grandpa hand out his next evolution stone on re-entry and -- re-arms every other once-per-visit latch (Kurt's house, the ship ports, -- Dragon's Den B1F, the Park gate). -- -- MapSetupScript_Continue is the one entry that does NOT: it runs -- HandleContinueMap, which is the label BELOW that reset, so loading a save -- keeps the byte SRAM was holding (data/maps/setup_scripts.asm). Without -- that exemption, saving inside Kurt's house and continuing re-arms the -- latch and he repeats the branch the player already saw. The post-credits -- spawn is not a continue: SpawnAfterE4 / PostCreditsSpawn set -- MAPSETUP_WARP, so it takes the reset (engine/menus/intro_menu.asm). if not opts.continue then self.events:resetMapBuffer() end -- ResetBikeFlags (home/flag.asm) zeroes the whole byte on a map load, and -- BIKEFLAGS_STRENGTH_ACTIVE_F is in it -- STRENGTH has to be used again in -- the next room, which is the whole shape of the Blackthorn Gym puzzle. self.strengthActive = false self.strengthMon = nil -- The other two bits of the same byte. They have to go BEFORE -- MAPCALLBACK_NEWMAP runs, because the Cycling Road's callback is what sets -- them straight back again: leaving them set is how one visit to Route 17 -- would keep the player glued to the bike for the rest of the game. self:setEngineFlag(Bike.ENGINE_ALWAYS_ON_BIKE, false) self:setEngineFlag(Bike.ENGINE_DOWNHILL, false) -- "Respawn in Pokemon Centers" (home/map.asm, LoadMapAttributes' .SetSpawn): -- walking from an OUTDOOR map into an INDOOR one whose tileset is -- TILESET_POKECENTER rewrites wLastSpawnMapGroup / wLastSpawnMapNumber, and -- that pair is what a whiteout reads. It is the only thing in the game that -- moves the respawn point, and it has to run on the map load rather than on -- the heal: the cart moves your spawn when you walk in the DOOR, whether or -- not you talk to the nurse. -- -- Missing this was not a small divergence. MrPokemonsHouse.asm does -- `blackoutmod CHERRYGROVE_CITY` in the first half hour of the game, so with -- no other writer the respawn point was pinned to Cherrygrove for the entire -- rest of the run: every whiteout at the Elite Four teleported the player -- back across Johto. (Found by the Gold route bot, which spent about 40k -- frames per Elite Four attempt walking home from Cherrygrove.) -- -- Divergence, deliberate: the cart stores the OUTDOOR map and resolves it -- through data/maps/spawn_points.asm to get coordinates inside that town's -- Pokecenter. No spawn table is emitted into the cache, and World:warpToSpawn -- already prefers a stored map id over the SPAWN_* lookup, so store the -- Pokecenter itself. Same building, same town; the landing tile is its door -- rather than the mat in front of the counter. -- -- A named method rather than eight inline lines so that -- tests/gen2_pokecenter_spawn_test.lua can drive the shipped rule instead of -- restating it: setMap needs a tileset, a Map and the callback machinery -- before it will run at all, which is more world than the rule needs. self:updateWhiteoutSpawn(def, mapId) -- ResetFlashIfOutOfCave: the FLASH flag survives a warp between two cave -- floors and dies the moment you step out onto a ROUTE or a TOWN. if def.environment == "ROUTE" or def.environment == "TOWN" then self.flashUsed = false end -- No noteFlypoint call here anymore: MAPCALLBACK_NEWMAP (run below, once -- the map is actually loaded) is a town's own `setflag ENGINE_FLYPOINT_*`, -- and FieldMoves.hasVisitedSpawn now reads that flag straight off -- save.engineFlags instead of a second write this function used to make. -- The map load repaints everything, so a fade sheet and a screen shake left -- over from the script that warped cannot survive it -- LoadMapPalettes and -- DeleteMapObject are what end both on the cart. self.fade = nil self.shake = nil self.map = Map.new(def, tileset) -- A follow pairing points at two live objects, and a map load rebuilds them -- (RefreshMapSprites); nothing on the cart survives that either. self.followState = nil -- EnterMap's SetUpFiveStepWildEncounterCooldown (engine/overworld/events.asm: -- 110, :367-370): four encounter-free steps after every map entry. self.wildCooldown = 5 -- Resolve colors before baking: an indoor map pinned to PALETTE_DAY and the -- town outside it are lit differently, so the daytime has to be settled -- before imageFor picks a cache key -- and before a TILES callback's -- `changeblock` re-bakes through World:refreshMapImages, which would -- otherwise spend that bake on the daytime of the map being LEFT. self:applyPalettes() -- 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] 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) end if not opts.seamless then self.player.moving = false self.player.progress = 0 self.player.targetX, self.player.targetY = nil, nil end else self.player = Player.new(cx, cy, face, chris) 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). self.player.inGrass = self:grassAt(cx, cy) self.player.grassShake = nil -- HandleNewMap (home/map.asm), in its own order: MAPCALLBACK_NEWMAP, then -- ClearCmdQueue, then MAPCALLBACK_CMDQUEUE. The queue never survives a map -- load on the cart either, which is why both maps that use one write it back -- from a callback rather than once at the start of the game. -- -- Every setup script that reaches this port's setMap -- Warp, BadWarp, Door, -- Fall, Teleport, Train, Connection -- carries HandleNewMap, LoadBlockData -- and LoadMapObjects, so all four callbacks belong here. The three that -- carry fewer are not setMap calls: `reloadmap` is World:rebuildPeople (no -- load at all), ReturnToMapFromSubmenu has no port equivalent, and the -- Continue script's exemptions (no NEWMAP, no OBJECTS) exist because the cart -- restores wMapObjects from the save -- the port derives object visibility -- from the flags instead, so a continue has to build it like any other load. self:runMapCallback("MAPCALLBACK_NEWMAP") CmdQueue.clear(self.cmdQueue) self:writeCmdQueue() -- LoadBlockData: MAPCALLBACK_TILES, with the block buffer already refilled -- from ROM by restoreBlocks above and nothing baked off it yet. self:runMapCallback("MAPCALLBACK_TILES") -- LoadMapGraphics has no failure arm (data/maps/setup_scripts.asm:41): a -- failed bake reports through self.status and the rest of the setup runs. self.mapImage = self:imageFor(mapId) if not self.mapImage then self.status = "Could not bake " .. tostring(mapId) end if opts.seamless then self.warpCooldown = nil else -- Don't re-trigger the arrival warp until the player steps off. self.warpCooldown = { x = cx, y = cy } end self:rebuildNeighbors() -- LoadMapObjects (engine/overworld/map_setup.asm): MAPCALLBACK_OBJECTS, and -- only THEN LoadObjectMasks / InitializeVisibleSprites. The callback's -- `appear` and `disappear` decide which objects the rebuild below finds -- -- which day of the week's traveller is standing on Route 29, whether Lugia is -- in its chamber -- so running it after the rebuild would show the previous -- day's answer until something else rebuilt. self:runMapCallback("MAPCALLBACK_OBJECTS") -- LoadObjectMasks itself, the load this map visit's masks come from. Every -- appear/disappear after it moves one byte of its own. self:loadObjectMasks() -- CheckUpdatePlayerSprite (engine/overworld/map_setup.asm), which every map -- setup script runs: the Cycling Road puts the player ON the bike, an -- INDOOR / DUNGEON map takes them off it, and the surf arms follow -- CheckOnWater -- the permission of the tile the player is STANDING on, which -- is why a load that lands on water is a surfing load and a warp out of the -- sea onto a beach is not. It has to be after MAPCALLBACK_NEWMAP, because -- that callback is where ENGINE_ALWAYS_ON_BIKE is set, and before the music -- below, which reads the state back. self:applyPlayerState(Bike.mapSetupState( self.playerState, def.environment, self:alwaysOnBike(), Permissions.isWater(self.map:cellCollision(cx, cy)))) self:rebuildPeople({ seamless = opts.seamless }) -- rebuildPeople may have pooled fresh NPCs; give them their colors too. self:applyPalettes() local audio = self.game and self.game.data and self.game.data.audio -- PlayMapMusicBike / SpecialMapMusic (home/audio.asm:335, :397): a biking -- player keeps the bike theme; Music.play dedupes seamless edge crossings. if audio and audio.runtime then if not (FieldMoves.isBiking(self.playerState) and self:playBikeMusic()) then Music.playMap(self.game.data, mapId, nil, FieldMoves.isSurfing(self.playerState)) end end -- Fires with the map fully built and BEFORE the map's own scene script, so a -- listener sees exactly the state that script does -- the position Gen 1's -- emit takes ahead of its onEnter chain. `via` carries the same four words -- Gen 1 uses plus "continue", which is the one map load Gen 2 has and Gen 1 -- does not (MapSetupScript_Continue, the save being resumed). -- Ahead of the emit, so a map.entered listener already sees this load's -- follower (src/world/OverworldController.lua:446's position). Follower.onMapEntered(self.game, self, opts, true) Runtime.emit("map.entered", { mapId = mapId, map = self.map, fromMapId = fromMapId, via = opts.via or (opts.continue and "continue") or (opts.seamless and "connection") or (fromMapId and "warp" or "boot"), }) -- Map-enter scene scripts (e.g. Elm lab walk-up at scene 0). if not opts.seamless then self.pendingSceneScript = true end -- StartMap (engine/overworld/events.asm): `farcall InitCallReceiveDelay` on -- every map entry, connections included -- which is why a player who keeps -- warping is never rung (src/core/gen2/Phone.lua's receive timer). if self.game and self.game.save then require("src.core.gen2.Phone").onMapLoad(self.game.save, self:stepContext().phone) end -- The setup script runs to its `db -1` either way, so the load reports true -- and only self.status carries a failed bake (data/maps/setup_scripts.asm:53). if self.mapImage then self.status = nil end return true end local function heldDirection(input) if input then if input:isDown("up") then return "up" end if input:isDown("down") then return "down" end if input:isDown("left") then return "left" end if input:isDown("right") then return "right" end return nil end if love.keyboard.isDown("up", "w") then return "up" end if love.keyboard.isDown("down", "s") then return "down" end if love.keyboard.isDown("left", "a") then return "left" end if love.keyboard.isDown("right", "d") then return "right" end return nil end -- PLAYEREVENT_WARP -> WarpToNewMapScript (engine/overworld/events.asm): -- -- WarpToNewMapScript: -- warpsound -- newloadmap MAPSETUP_DOOR -- end -- -- so a warp taken by walking onto the tile is two things, in that order: the -- sound GetWarpSFX picks off the tile the player is STANDING on (which is why -- it has to be read before the load), and the MAPSETUP_DOOR setup script with -- the map load inside it. This used to be five lines that called setMap -- directly, which is why doors were silent and instant. -- home/map.asm GetDestinationWarpNumber: a `warp_event` whose destination warp -- number is -1 ($ff) does NOT name its own destination. It takes the whole -- triple -- warp number, map group, map number -- out of wBackupWarpNumber and -- friends, which is how one elevator door leads to seven different floors and -- why Elevator_GoToFloor rides without warping. A $ff warp with nothing -- written there yet keeps the destination the map declared. function World:resolveWarp(warpDef) if warpDef.destWarp ~= 0xff then return warpDef.destMap, warpDef.destWarp end local backup = self.backupWarp if not (backup and backup.map and self.maps[backup.map]) then return warpDef.destMap, warpDef.destWarp end return backup.map, backup.warp end -- The other half of the -1 contract, from the arrival side. CopyWarpData -- (home/map.asm) stores the warp stepped ON and the map being left in -- wPrevWarp / wPrevMapGroup / wPrevMapNumber on every warp taken, and -- LoadMapAttributes' warp-coordinate read copies that triple into -- wBackupWarpNumber / wBackupMapGroup / wBackupMapNumber whenever the warp -- ARRIVED ON declares destination warp -1. That refresh is what lets the one -- shared POKECENTER_2F staircase lead back down into whichever centre's -- stairs were climbed, and what tells an elevator door which floor it was -- entered from before Elevator_GoToFloor overwrites the triple with the -- chosen row. Without it the -1 warp resolves to nothing and the tile is -- simply dead -- the player is trapped upstairs in every Pokemon Center. -- -- EnterMapWarp's `.SaveDigWarp` (home/map.asm) is the second writer, and it -- writes the same triple: walking through a door out of an OUTDOOR map -- (CheckOutdoorMap -- ROUTE or TOWN) into an INDOOR one (CheckIndoorMap -- -- INDOOR, CAVE, DUNGEON or GATE) records the door used, so Dig and Escape Rope -- pay out to THAT entrance rather than to whichever one banked the triple -- last. MOUNT_MOON_SQUARE and TIN_TOWER_ROOF are the routine's own two -- exceptions: outdoor maps sitting inside indoor ones, which the rope must -- never drop the player onto. The cart keeps this in wDigWarpNumber and -- friends; this port banks one triple for both readers, which is what -- World:escapeRopeTarget resolves through. local DIG_WARP_OUTDOOR = { ROUTE = true, TOWN = true } local DIG_WARP_INDOOR = { INDOOR = true, CAVE = true, DUNGEON = true, GATE = true, } local DIG_WARP_EXCLUDED = { MOUNT_MOON_SQUARE = true, TIN_TOWER_ROOF = true, } function World:recordWarpBackup(prevMapId, prevWarpIndex, arrivalWarp, destMapId) if not (prevMapId and prevWarpIndex) then return end if arrivalWarp and arrivalWarp.destWarp == 0xff then self.backupWarp = { warp = prevWarpIndex, map = prevMapId } return end if DIG_WARP_EXCLUDED[prevMapId] then return end local from = self.maps and self.maps[prevMapId] local into = destMapId and self.maps and self.maps[destMapId] if not (from and into) then return end if not DIG_WARP_OUTDOOR[from.environment] then return end if not DIG_WARP_INDOOR[into.environment] then return end self.backupWarp = { warp = prevWarpIndex, map = prevMapId } end -- wPrevWarp's find: the index of the warp event being stepped on, in the map -- that declared it. Warp defs are shared tables, so identity is the match. function World:warpIndexOf(warpDef) local warps = self.map and self.map.def and self.map.def.warps for index, row in ipairs(warps or {}) do if row == warpDef then return index end end return nil end -- the resolved destination passes through warp.destination, so a mod can -- reroute one door without owning the warp table -- the same three returns and -- the same vanilla link src/world/Warp.lua uses under Gen 1 local function warped(mapId, x, y) return mapId, x, y end function World:takeWarp(warpDef) if not warpDef or not warpDef.destMap then return false end local destMapId, destWarpNumber = self:resolveWarp(warpDef) local dest = self.maps[destMapId] if not dest then return false end local destWarp = dest.warps and dest.warps[destWarpNumber] if not destWarp then return false end local destX, destY = destWarp.x, destWarp.y -- ctx keeps Gen 1's three keys. `lastMap` is the -1 backup triple -- (World:recordWarpBackup), which is Gen 2's version of the remembered -- outdoor side Gen 1 resolves LAST_MAP through; `destWarp` is the warp -- NUMBER the resolve landed on, which Gen 1's warp table has no equivalent -- of. A reroute onto a map this cache does not hold is refused here rather -- than left for setMap, so the sound and the backup writes never happen for -- a warp that cannot be taken. if Runtime.wantsHook("warp.destination") then destMapId, destX, destY = Runtime.call("warp.destination", warped, destMapId, destX, destY, { warp = warpDef, lastMap = self.backupWarp, destWarp = destWarpNumber, data = self.game and self.game.data, maps = self.maps }) if not (destMapId and self.maps[destMapId] and destX and destY) then return false end end self:warpSound() -- wBackupMapGroup / wBackupMapNumber: the map being LEFT. The elevator's -- .FindCurrentFloor is the only thing that reads it, and it is what makes -- "Now on:" say the floor you got in from. self.backupMapId = self.map and self.map.id -- wPrevWarp / wPrevMapGroup / wPrevMapNumber, read before the load pulls -- the source map out from underfoot. local prevMapId = self.map and self.map.id local prevWarpIndex = self:warpIndexOf(warpDef) -- Gen 1's five payload keys, unchanged, and the coordinates are the HOOKED -- ones so a listener and a warp.destination wrapper never disagree about -- where the player went. `toWarp` is the destination warp number, which the -- Gen 1 warp record has no field for. Runtime.emit("player.warped", { fromMap = prevMapId, toMap = destMapId, x = destX, y = destY, warp = warpDef, toWarp = destWarpNumber }) return self:runMapSetup(MAPSETUP_DOOR, function() local ok = self:setMap(destMapId, destX, destY, (self.player and self.player.facing) or "down") if ok then self:spawnFacing() self:recordWarpBackup(prevMapId, prevWarpIndex, destWarp, destMapId) end return ok end) end -- RefreshPlayerSprite (engine/overworld/map_objects.asm) is the whole rule for -- which way a map load leaves the player pointing: CheckWarpFacingDown against -- the tile they ARRIVE on, then `call c, SpawnInFacingDown`. A tile that is -- not in that array keeps the facing they walked in with -- so you enter a -- building still facing up (the mat inside is a COLL_WARP_CARPET_*) and step -- out of one facing the street (the doorway outside is COLL_DOOR). -- -- It runs AFTER the load for the same reason the cart's does: the array is -- indexed by wPlayerTileCollision, which is the DESTINATION map's tile. -- -- This replaces a guess that compared the destination Y against the map height. -- That agreed with the cart at New Bark Town's two ends by luck and had no -- reason to anywhere else: a ladder in the middle of a cave floor is neither -- the top row nor the bottom one. function World:spawnFacing() local p = self.player if not (self.map and p) then return end if Permissions.warpFacesDown(self.map:cellCollision(p.cellX, p.cellY)) then p.facing = "down" end end -- Seamless edge cross: swap map data, park the player one cell before the -- landing (same world pixels the neighbor strip already showed), and keep -- the step running so the seam does not hitch. function World:tryConnection(dir) local connKey = DIR_CONN[dir] local conn = self.map:connection(connKey) if not conn or not conn.mapId then return false end local dest = self.maps[conn.mapId] if not dest then return false end local x, y = Map.connectionLanding( dest, conn, dir, self.player.cellX, self.player.cellY) if not x then return false end local destMap = Map.new(dest, self.tilesets[dest.tileset]) -- A surfing crossing lands on water, which isWalkable refuses; the arm the -- step would have taken is what decides, the same as it does inside the map. local landable if FieldMoves.isSurfing(self.playerState) then landable = Permissions.surfable(destMap:cellCollision(x, y)) ~= nil else landable = destMap:isWalkable(x, y) end if not landable then return false end local p = self.player local d = Map.DELTA[dir] self:setMap(conn.mapId, x, y, dir, { seamless = true }) p.cellX, p.cellY = x - d[1], y - d[2] p.px, p.py = p.cellX * 16, p.cellY * 16 p.facing = dir p.targetX, p.targetY = x, y p.moving = true p.progress = 0 FixedStep:discardCatchup() return true end -- .TranslateIntoMovement (engine/overworld/player_movement.asm) picks the arm -- off wPlayerState, and .Normal and .Surf differ in exactly two things: which -- permission test the step runs (.CheckLandPerms against LAND_TILE versus -- .CheckSurfPerms, which takes LAND and WATER alike), and what a LAND answer -- means -- an ordinary step for one, .ExitWater for the other. -- -- Player:tryMove owns the turn-in-place timing and the step bookkeeping, and -- none of that changes between the two arms; only the map's answer does. So -- the surf arm hands tryMove a proxy that answers .CheckSurfPerms rather than -- growing a second copy of the timing. function World:surfMap(map) map = map or self.map return { inBounds = function(_, x, y) return map:inBounds(x, y) end, isWalkable = function(_, x, y) return Permissions.surfable(map:cellCollision(x, y)) ~= nil end, } end -- A map proxy that refuses every step but keeps bounds honest. Handed to -- tryMove when GetMovementPermissions forbids the direction: the press still -- has to TURN the player (the cart's .bump path runs after the facing is -- written), so the refusal cannot short-circuit above tryMove. local function refusingMap(map) return { inBounds = function(_, x, y) return map:inBounds(x, y) end, isWalkable = function() return false end, } end -- the movement.speed chain's vanilla link, hoisted so an empty chain allocates -- no closure on a per-step path local function sameFrames(frames) return frames end -- .TryJump: the refused step becomes a two-cell STEP_LEDGE when the player -- STANDS on a ledge tile whose .ledge_table row includes the facing. The cart -- runs it after .TryStep fails for any reason (permission or NPC alike). The -- landing tile is checked here where the cart does not bother -- no real map -- has a blocked landing, and a hop into scenery would strand the player. function World:tryLedgeJump(dir) local p, map = self.player, self.map local facings = Permissions.ledgeFacings( map:cellCollision(p.cellX, p.cellY)) if not (facings and facings[dir]) then return false end local d = Map.DELTA[dir] local tx, ty = p.cellX + d[1] * 2, p.cellY + d[2] * 2 if not map:inBounds(tx, ty) then return false end if not map:isWalkable(tx, ty) then return false end for _, e in ipairs(self.entities or {}) do if e ~= p then if e.cellX == tx and e.cellY == ty then return false end if e.moving and e.targetX == tx and e.targetY == ty then return false end end end p.targetX, p.targetY = tx, ty p.moving = true p.jumping = true -- JumpStep res IN_GRASS_F and calls neither UpdateTallGrassFlags nor -- ShakeGrass (engine/overworld/movement.asm:741-770). p.inGrass, p.grassShake = false, nil p.progress = 0 -- engine/overworld/map_objects.asm:1163 p.stepFrames = Player.STEP_FRAMES * 2 self:playSfxNamed("Sfx_JumpOverLedge", SFX_JUMP_OVER_LEDGE) return true end -- NormalStep's begin-of-step grass work (engine/overworld/movement.asm:657-674); -- UpdateTallGrassFlags only RE-tests while IN_GRASS is set (map_objects.asm:226). function World:playerStepGrass() local p = self.player if not (p and p.moving) or p.jumping or (p.progress or 0) ~= 0 then return end local grass = self:grassAt(p.targetX or p.cellX, p.targetY or p.cellY) if p.inGrass then p.inGrass = grass end p.grassShake = grass or nil end function World:movePlayer(dir) local p, map = self.player, self.map -- .DoStep's choice between STEP_WALK and STEP_BIKE, made fresh for every -- step: a step already under way keeps the duration it started with, and the -- downhill exception means the answer can change from cell to cell. p.stepFrames = Bike.stepFrames( self.playerState, dir, self:downhill(), Player.STEP_FRAMES) -- movement.speed, the same name and the same ctx keys src/world/Player.lua -- offers under Gen 1 (running shoes, dash, a bike that is not the bike). -- Gen 2 adds `downhill` and `playerState`, because Gold's own answer already -- depends on both: the Cycling Road forces a step whose duration changes from -- cell to cell. Per-step hot path, so the ctx is only built when a chain -- exists. if Runtime.wantsHook("movement.speed") then local save = self.game and self.game.save local frames = Runtime.call("movement.speed", sameFrames, p.stepFrames, { onBike = FieldMoves.isBiking(self.playerState), surfing = FieldMoves.isSurfing(self.playerState), downhill = self:downhill() and true or false, playerState = self.playerState, player = p, input = self.game and self.game.input, save = save, }) p.stepFrames = math.max(1, math.floor(tonumber(frames) or p.stepFrames)) end -- GetMovementPermissions: the standing tile's side-wall kind and Gold's -- neighbour arms veto the direction before any walkable test runs. Both -- .CheckLandPerms and .CheckSurfPerms read the same wTilePermissions, so the -- veto applies to walking and surfing alike. local permitted = Permissions.stepPermitted( function(x, y) return map:cellCollision(x, y) end, p.cellX, p.cellY, dir) -- `.CheckNPC`'s IsNPCAtCoord answers for a BIG_OBJECT's whole 2x2 blob, and -- Player:tryMove's entity scan only ever compares the one cell an object -- stands on -- so the three cells the Vermilion Snorlax overhangs are vetoed -- here instead. Same refusal shape as the permission veto above: the press -- still has to turn the player, so it cannot short-circuit tryMove. if permitted then local d = Map.DELTA[dir] local npc = d and self:npcAt(p.cellX + d[1], p.cellY + d[2]) if npc and npc.bigObject then permitted = false end end local result if not FieldMoves.isSurfing(self.playerState) then result = p:tryMove(dir, permitted and map or refusingMap(map), self.entities) if result == "blocked" and self:tryLedgeJump(dir) then result = "moved" end else result = p:tryMove(dir, permitted and self:surfMap(map) or refusingMap(map), self.entities) if result == "moved" and Permissions.surfable(map:cellCollision(p.targetX, p.targetY)) == "land" then -- .ExitWater: GetOutOfWater writes PLAYER_NORMAL before .DoStep, then -- PlayMapMusic swaps the surf theme back (home/audio.asm:308) self:applyPlayerState(FieldMoves.PLAYER_NORMAL) local audio = self.game and self.game.data and self.game.data.audio if audio and audio.runtime then Music.playMap(self.game.data, map.id, nil, FieldMoves.isSurfing(self.playerState)) end end end -- .DoStep's .FinishFacing latch, and .StandInPlace / ._WalkInPlace clearing -- it. A successful step OR turn records the direction; a bump (blocked / -- edge) is what ends an ice slide. if result == "moved" or result == "turned" then self.turningDirection = dir elseif result == "blocked" or result == "edge" then self.turningDirection = nil end -- NormalStep, in order (engine/overworld/movement.asm:657-674): InitStep has -- already moved OBJECT_TILE_COLLISION onto the destination. if result == "moved" then self:playerStepGrass() end return result end function World:clearWarpCooldownIfLeft() local cool = self.warpCooldown if not cool then return end local p = self.player if p.cellX ~= cool.x or p.cellY ~= cool.y then self.warpCooldown = nil end end function World:warpsSuppressed() local cool = self.warpCooldown if not cool then return false end local p = self.player return p.cellX == cool.x and p.cellY == cool.y end -- Answers whether it TOOK a warp, and World:step has to end the step on a true. -- Stepping onto a warp tile is PLAYEREVENT_WARP: the cart writes wScriptRunning -- and DoPlayerEvent hands the frame to WarpToNewMapScript, so the rest of that -- frame's overworld loop -- including DoPlayerMovement -- never runs. Falling -- through to World:movePlayer instead is what let a still-held direction take -- one free step on the far side of the door, which put the player one cell too -- far into Elm's Lab before the map's own scene script could start walking them -- (ElmsLab_WalkUpToElmMovement is nine steps from the mat and ended at (4,1) -- rather than (4,2)). function World:checkWarpOnArrive() local p = self.player local coll = self.map:cellCollision(p.cellX, p.cellY) if not Permissions.isWarpCollision(coll) then return false end local entry = self.map:warpAt(p.cellX, p.cellY) if not entry then return false end if Permissions.isImmediateWarp(coll) then if self:warpsSuppressed() then return false end return self:takeWarp(entry.def) and true or false end local need = Permissions.carpetDirection(coll) if need and self.heldDir == need then return self:takeWarp(entry.def) and true or false end return false end function World:checkCarpetWhileStanding() local p = self.player if p.moving or not self.heldDir then return false end local coll = self.map:cellCollision(p.cellX, p.cellY) local need = Permissions.carpetDirection(coll) if need and self.heldDir == need then local entry = self.map:warpAt(p.cellX, p.cellY) if entry then self:takeWarp(entry.def) return true end end return false end -- --------------------------------------------------------------------------- -- Map callbacks (home/map.asm RunMapCallback). -- --------------------------------------------------------------------------- -- RunMapCallback.FindCallback: walk the map's callback list and take the FIRST -- row whose execution index matches. A second row of the same type is dead on -- the cart, so it is dead here. function World:mapCallbackScript(kind) local def = self.map and self.map.def for _, cb in ipairs((def and def.callbacks) or {}) do if cb.callback == kind and cb.scriptKey then return cb.scriptKey end end return nil end -- RunMapCallback proper. Every one of these is a script, run through the same -- interpreter every other script goes through -- see Vm:runCallback for why it -- is a nested run rather than Vm:start, and why it cannot block. -- -- The five types and where the cart runs each: -- -- MAPCALLBACK_NEWMAP HandleNewMap, before the block buffer is filled -- MAPCALLBACK_CMDQUEUE HandleContinueMap, right after ClearCmdQueue -- MAPCALLBACK_TILES LoadBlockData, after the buffer is refilled from ROM -- MAPCALLBACK_OBJECTS LoadMapObjects, before the object list goes live -- MAPCALLBACK_SPRITES LoadUsedSpritesGFX -- no Gold map has one -- -- Gold's 84 callbacks are 39 NEWMAP, 24 OBJECTS, 19 TILES and 2 CMDQUEUE, and -- between them they are the flypoint flags, Mom's worried phone call, the day -- of the week's travelling NPCs, the Elite Four doors sealing behind you, the -- Ruins of Alph floors, the Goldenrod underground doors, and the slot that puts -- Sudowoodo on the map at all. function World:runMapCallback(kind) local key = self:mapCallbackScript(kind) if not (key and self.vm) then return false end return self.vm:runCallback(key) and true or false end -- --------------------------------------------------------------------------- -- wCmdQueue (engine/overworld/cmd_queue.asm, home/stone_queue.asm). -- --------------------------------------------------------------------------- -- MAPCALLBACK_CMDQUEUE, and the `writecmdqueue` command that is its whole body -- on both maps that have one. -- -- The extracted callback is preferred: maps.lua now carries the map's callback -- list and the extractor follows `writecmdqueue`'s operand through the cmdqueue -- struct into the stonetable, so the rows and their per-boulder scripts come -- out of the cart. CmdQueue.STONE_TABLES stays as the fallback for a cache -- built before that, and is what the pins in tests/gen2_world_test.lua compare -- the extracted rows against. -- -- This READS the callback script rather than running it, which is enough -- because `writecmdqueue` / `endcallback` is the whole body on both maps -- and -- it has to, because CmdQueue.write wants the entry back as a value while the -- command's own hook only answers a boolean. World:writeCmdQueue is what the -- callback would have called; running the body as well would write the queue -- twice into two of its four slots. function World:extractedCmdQueue() local key = self:mapCallbackScript("MAPCALLBACK_CMDQUEUE") if not key then return nil end for _, cmd in ipairs(self.scripts[key] or {}) do if cmd.op == "writecmdqueue" then local entry = CmdQueue.fromExtracted(cmd.queue, self.map.id) if entry then return entry end end end return nil end function World:writeCmdQueue() if not self.map then return false end local entry = self:extractedCmdQueue() or CmdQueue.mapEntry(self.map.id) if not entry then return false end return CmdQueue.write(self.cmdQueue, entry) ~= nil end function World:delCmdQueue(kind) return CmdQueue.delete(self.cmdQueue, kind) end -- HandleCmdQueue, once a frame from the overworld loop -- so not while a script -- is up, which is also what stops the queue re-firing on the boulder it is -- already busy removing. -- -- The objects handed over are the port's NPCs, wearing the CART's object ids -- (`object_const_def` is `const_def 2`, and `disappear` already speaks that -- numbering), because those are what a stonetable row names. function World:handleCmdQueue() if not (self.map and self.vm) then return false end if self:busy() then return false end if CmdQueue.count(self.cmdQueue) == 0 then return false end local objects = {} for _, npc in ipairs(self.npcs) do local obj = npc.def if obj and obj.index then objects[#objects + 1] = { id = obj.index + 1, movement = obj.movement, cellX = npc.cellX, cellY = npc.cellY, moving = npc.moving and true or false, } end end local map = self.map local row = CmdQueue.poll(self.cmdQueue, { objects = objects, warps = (map.def and map.def.warps) or {}, collisionAt = function(x, y) return map:cellCollision(x, y) end, }) if not row then return false end -- CallMapScript + EnableScriptMode: the row's script runs like any other. return self.vm:start(row.script) end -- --------------------------------------------------------------------------- -- The per-step event chain (engine/overworld/events.asm CountStep). -- -- src/world/gen2/StepEvents.lua owns the ORDER and the counters; everything -- here is the presentation the cart's player-event scripts put over it. -- --------------------------------------------------------------------------- local function monName(mon) if type(mon) ~= "table" then return "?" end return mon.nickname or mon.name or mon.species or "?" end -- What CountStep's routines need from outside the save. function World:stepContext() local def = self.map and self.map.def return { data = self.game and self.game.data, phone = { map = def, maps = self.maps, daytime = self.daytime, clock = self.game and self.game.clock, }, -- GetMapPhoneService: zero means the map HAS service, which maps.lua has -- already decoded into a boolean. phoneService = def and def.phoneService, playerState = self.playerState, -- "Don't count steps in link communication rooms." This port has link -- play but no link overworld room, so the gate can only ever be false -- -- it is written out so the day one is added nobody has to rediscover it. linkMode = false, } end -- CheckTileEvent calls this between the coord events and the wild roll, and a -- CARRY out of it queues a player event. Answers true when the caller must -- stop the step: an egg that hatched or a mon that dropped to poison does not -- also walk into a Rattata. function World:countStep() local save = self.game and self.game.save if not save then return false end local event = StepEvents.count(save, self:stepContext()) if not event then return false end if event.kind == "hatch" then -- PLAYEREVENT_HATCH is HatchEggScript, and HatchEggScript is one command: -- `callasm OverworldHatchEgg / end`. Routed through the callasm registry -- rather than called straight so the routine has exactly one port, whether -- it is reached from here or from a script that hits its address. CallAsm.run(self, "OverworldHatchEgg") elseif event.kind == "poisonFaint" then self:poisonFaintScript(event) elseif event.kind == "poisonHurt" then -- .PlayPoisonSFX alone: the sound and the four-frame BG flash, no script. CallAsm.run(self, "PlayPoisonSFX") elseif event.kind == "repel" then self:repelWoreOff() elseif event.kind == "phoneCall" then self:receivePhoneCall(event.call) end return event.blocks and true or false end -- RepelWoreOffScript (engine/events/repel.asm): opentext, one line, waitbutton. function World:repelWoreOff() self:showText(Strings("REPEL's effect\nwore off.")) end -- .Script_MonFaintedToPoison, via .CheckWhitedOut. One line per mon that -- actually dropped, HAPPINESS_POISONFAINT applied to each, and then -- CheckPlayerPartyForFitMon decides between closing the box and the whiteout. -- -- The order is the cart's: the happiness hit lands BEFORE the text, and the -- whiteout only after every fainted mon has been named. function World:poisonFaintScript(event) local save = self.game and self.game.save local party = (save and save.party) or {} local Happiness = require("src.core.gen2.Happiness") local lines = {} for _, index in ipairs(event.fainted or {}) do local mon = party[index] if mon then Happiness.change(mon, "HAPPINESS_POISONFAINT") lines[#lines + 1] = Strings("%s\nfainted!", monName(mon)) end end CallAsm.run(self, "PlayPoisonSFX") local i = 0 local function next() i = i + 1 if lines[i] then self:showText(lines[i], next) return end if event.whiteout then self:whiteOut() end end next() end -- OverworldWhiteoutScript's tail, for the poison path only: the Bug Contest -- abort belongs to the script and is not modelled here, so this is -- HalveMoney (the wallet only -- Mom's savings, if any, are untouched: the -- cart's own HalveMoney shifts wMoney alone) plus the trip back to the spawn -- point. -- -- Both callasm halves go through the registry: HalveMoney is the 24-bit -- `srl a / rra / rra` and GetWhiteoutSpawn is the IsSpawnPoint check that -- falls back to SPAWN_HOME. Only the first has an effect today -- -- World:warpToSpawn prefers the `blackoutmod` map itself over the SPAWN_* row -- it matches -- but running the pair keeps the script order honest. function World:whiteOut() self:showText( Strings("You have no more\nPOKéMON that can\011fight!"), function() CallAsm.run(self, "HalveMoney") CallAsm.run(self, "GetWhiteoutSpawn") self:healParty() -- Guarded because healPoint walks the spawn table to answer: with nobody -- listening the blackout must not pay for a lookup warpToSpawn is about to -- make again anyway. if Runtime.wants("world.blacked_out") then Runtime.emit("world.blacked_out", { save = self.game and self.game.save, healTarget = self:healPoint() }) end self:warpToSpawn() end) end -- HatchEggs (engine/pokemon/breeding.asm). One slot at a time, in party order, -- for every egg whose counter has reached zero: -- -- "Huh?" (a `para "@"`, so the box clears and waits) -- EggHatch_AnimationSequence -- an empty box (_BreedClearboxText) -- " cameout of its EGG!" with sound_caught_mon -- "Give a nickname to?" -> the naming screen, or not -- -- These four strings are hand-written from data/text/common_2.asm rather than -- read out of text.lua, for the same reason the Pokegear's radio lines are: -- the extractor only follows text a SCRIPT points at, and nothing points at -- this block. The animation itself is src/ui/gen2/EggHatchAnim.lua. function World:hatchEggs() local save = self.game and self.game.save local data = self.game and self.game.data if not (save and data) then return end local Breeding = require("src.core.gen2.Breeding") local queue = Breeding.readyToHatch(save) local at = 0 local function nextEgg() at = at + 1 local index = queue[at] if not index then -- RestartMapMusic: the standard menu header the hatch ran under is gone. self:restoreMapMusic() return end self:showText("Huh?", function() local hatched, effects = Breeding.hatch(data, save, index) if not hatched then return nextEgg() end -- Breeding.hatch already ran SetSeenAndCaughtMon; the Togepi flag is the -- one side effect it hands back rather than setting, because wEventFlags -- belongs to the world. Events are keyed by NUMBER, the way the -- extractor emits them, so the constant is resolved here. if effects and effects.togepi and self.events then self.events:set(EVENT_TOGEPI_HATCHED, true) self.peopleDirty = true end local name = monName(hatched) local function announce() -- `sound_caught_mon` sits inside _BreedEggHatchText, before its -- text_promptbutton: the jingle plays as the line lands, not after it. self:playSfxNamed("Sfx_CaughtMon", 2) self:showText(Strings("%s came\nout of its EGG!", name), function() -- _BreedAskNicknameText ends `done`, not `prompt`, so YesNoBox opens -- over the question with no button press in between. That is exactly -- what askYesNo's instant re-show of lastText does. self.lastText = Strings("Give a nickname to\n%s?", name) self:askYesNo(function(yes) if not yes then return nextEgg() end self:nameHatchling(hatched, nextEgg) end) end) end -- EggHatch_AnimationSequence sits between the "Huh?" box and the line -- above (engine/pokemon/breeding.asm:664). It is a whole screen -- it -- blanks the map and takes the music -- so it goes on the stack; with no -- stack to push onto (a headless run) the beat is simply skipped, the -- way every other Gold cutscene degrades. local game = self.game if game and game.stack then Screens.push(game, "Gen2EggHatchAnim", { mon = hatched, species = hatched.species, menuGfx = data.gen2MenuGfx, onDone = function() game.stack:pop() announce() end, }) else announce() end end) end nextEgg() end -- `ld b, NAME_MON / farcall NamingScreen`, with wStringBuffer1 (the species -- name) already in the header slot. A cancelled screen is the same as "no -- thanks": InitName copies the species name back over the nickname either way. function World:nameHatchling(mon, onDone) local game = self.game if not (game and game.stack) then return onDone() end local data = game.data or {} local icons = data.gen2Icons local iconId = icons and icons.species and icons.species[mon.species] local entry = iconId and icons.icons and icons.icons[iconId] local done = function(name) game.stack:pop() -- _InitString's blank test, not a length one: "zero or more spaces -- followed by a null" (home/string.asm:6-30), and the keyboard's blank -- cells are real typeable characters, so an all-space entry reaches here -- and has to fall back to the species name the same way an empty one does. if name and name:gsub(" ", "") ~= "" then mon.nickname = name end onDone() end Screens.push(game, "Gen2NamingScreen", { type = "nickname", monName = mon.name or mon.species, iconPath = entry and entry.image or nil, menuGfx = data.gen2MenuGfx, onDone = done, onCancel = function() done(nil) end, }) end -- Script_ReceivePhoneCall's overworld half. The caller's script lives in ROM -- bank $41, which the extractor reaches by seeding its queue from PhoneContacts -- and SpecialPhoneCallList -- no map points into that bank, so those two tables -- are the only way in. The script runs inside the ring chrome -- (src/core/gen2/PhoneRing.lua: the SFX_CALL page, the Click!, the countdown -- restart), and wCurCaller is parked on the VM first so GetCallerLocation's -- two specials (RandomPhoneMon / RandomPhoneWildMon) know who is talking. -- -- The drop path below is kept for a cache built before that. Clearing the -- queue when a call has no body is not tidying-up, it is required: the cart -- does NOT count a step on which a special call fires, so a call that can never -- run would freeze wStepCount forever and stop eggs hatching outright -- the -- exact bug this chain exists to fix. World.unrunnableCalls = nil function World:receivePhoneCall(call) local key = call and call.scriptKey if key and self.vm and self.vm.scripts[key] then local Phone = require("src.core.gen2.Phone") local name, className = Phone.contactName(call.contact, self.game and self.game.data and self.game.data.trainers) self.vm.curPhoneCaller = call.contact local rows = require("src.core.gen2.PhoneRing").script(call, name, className) if self.vm:start(rows) then return true end end local save = self.game and self.game.save if save then require("src.core.gen2.Phone").clearSpecialCall(save) end self.unrunnableCalls = (self.unrunnableCalls or 0) + 1 if self.unrunnableCalls == 1 then print(("[gold] special phone call %s has no script in this cache " .. "(re-import: bank $41 is reached from PhoneContacts); dropped so the " .. "step counter keeps running") :format(tostring(call and call.specialName))) end return false end function World:updatePeople() for _, npc in ipairs(self.npcs) do npc:update(self.map, self.entities) end for _, g in ipairs(self.ghosts) do g.npc:update(g.map, g.peers) end end -- Once a second, ask whether the clock rolled into a new time of day; if it -- did, drop the baked map images so they come back in the new palette. The -- cart does this from UpdateTimePals on the same cadence. local PALETTE_POLL_STEPS = 60 function World:pollTimeOfDay() self.paletteClock = (self.paletteClock or 0) + 1 if self.paletteClock < PALETTE_POLL_STEPS then return end self.paletteClock = 0 if self:applyPalettes() then self.mapImages = {} self.mapImage = self:imageFor(self.map.id) self:rebuildNeighbors() end -- The hour-window objects (World:objectTimeVisible) key off the raw hour, -- not just the palette daytime, so their respawn rides the same poll: on the -- cart a reload is what refreshes wObjectMasks, and this poll is the port's -- stand-in for the player never being handed a stale mask for long. local hour = self:hour() if hour == self.lastMaskHour then return end -- The first poll only arms the latch; there is nothing to respawn yet. if self.lastMaskHour == nil then self.lastMaskHour = hour return end -- A rollover that lands while the world is busy is NOT consumed: the latch -- stays on the old hour so the next poll tries again. Advancing it here -- would swallow the only edge this hour has, leaving the map with the -- previous hour's masks until the next boundary or a map reload. if self:busy() then return end self.lastMaskHour = hour -- The hour half of LoadObjectMasks (GetObjectTimeMask), and only that half: -- keepScripted leaves every byte a scene wrote alone, so a beast that jumped -- away or an NPC a script sent home does not walk back in at the top of the -- hour. self:loadObjectMasks({ keepScripted = true }) self:rebuildPeople({ seamless = true }) end -- Both tick once per logic frame AFTER the body -- where -- src/world/OverworldController.lua:1039 drives the Gen 1 pair. The body has -- a dozen early returns, so the tail cannot live inside it. function World:step() self:stepBody() if not self.map or not self.player then return end Follower.update(self.game, self) Gen1Facade.worldTick(self, 1 / 60) end function World:stepBody() if not self.map or not self.player then return end self:pollTimeOfDay() -- ShakeScreen and the `musicfadeout` tail both run UNDER a script (the VM is -- parked on the earthquake's own waitFrames while the screen is still -- rattling), so both tick above the busy() gate rather than below it. if self.shake then self:updateShake() end -- The map setup chain ticks above the busy() gate for the same reason: it IS -- what closes that gate, so nothing below can be allowed to advance it. if self.mapSetup then self:updateMapSetup() return end if self.pendingMusic then self:updateMusicFade() end if self.moveState then self:updateMovement() end -- Above the VM tick: a `waitbutton` under a `pokepic` is parked on this -- poll, and its resume has to run inside the same frame the press lands on. if self.waitButton then self:pollWaitButton() end if self.vm then self.vm:update() end -- ExitAllMenus takes the balance box down with everything else the script -- opened, so a box asked for by a script that then ended without a menu must -- not be waiting for the NEXT script's menu to inherit it. if self.scriptBalance and self.vm and not self.vm:running() then self.scriptBalance = nil end -- ExitAllMenus again, for the box a `stay` left standing: the two consumers -- (World:askYesNo, and the next page of the same MapTextbox in World:showText) -- both clear the latch before the script ends, so a box still held here is one -- whose script stopped early -- a `sjump` out of the arm, or an `end` a mod -- inserted. Without this it would sit there forever with self.textbox set, -- and World:busy() would never let the player move again. if self.stayedTextBox and self.vm and not self.vm:running() then local held = self.stayedTextBox self.stayedTextBox = nil self.textbox = nil if self.game and self.game.stack and self.game.stack:top() == held then self.game.stack:pop() end end -- The rod cast and the tree shake tick alongside the VM rather than under -- the busy() gate below, because that gate is what they themselves close. if self.fishing then self:updateFishing() end if self.headbutt then self:updateHeadbutt() end if self.fieldMove then self:updateFieldMove() end -- QueueScript's own drain: a field move chosen from the party menu runs the -- first frame the overworld is back on top. if self.queuedFieldMove then self:runQueuedFieldMove() end -- The same drain for the ITEMFINDER's queued script. if self.queuedScript then self:runQueuedScript() end self:pollCaveFlicker() self:pollTileAnim() -- Any object visibility a running script changed lands here, once the -- script is over: RefreshMapSprites' timing, not the flag write's. if self.peopleDirty and not self:scriptRunning() then self.peopleDirty = nil self:rebuildPeople({ seamless = true }) end -- UnfreezeAllObjects (engine/overworld/map_objects.asm), which EndScript -- runs: the talked-to object and every scripted stepper get their movement -- functions back the frame the whole interaction -- script, text boxes, -- pending movement -- has settled. if self.frozeNpcs and not self:busy() then self.frozeNpcs = nil -- The pool is the superset of the live lists, but a rebuild between the -- freeze and here can leave a frozen NPC only in self.npcs, so both walk. for _, npc in pairs(self.npcPool or {}) do npc.frozen = false end for _, npc in ipairs(self.npcs or {}) do npc.frozen = false end end -- WarpCheck's find, for the same reason and on the same clock: a script that -- ends standing on a warp tile takes it once the script is over, never -- inside the command that noticed it. if self.pendingWarp and not self:scriptRunning() then if self:takePendingWarp() then return end end if self.emote then self.emote.left = self.emote.left - 1 if self.emote.left <= 0 then self.emote = nil end end -- The heal machine runs while the script is parked on its specialwait, so -- it ticks here above the input gate the same way the emote does; its -- last flash's onDone is what resumes the nurse. if self.healAnim then self:stepHealAnim() end -- HandleCmdQueue sits in the overworld loop, once a frame, above the input -- gate: it is what drops a boulder that is already sitting on a hole, and it -- has to see the frame the push finishes on. if self:handleCmdQueue() then return end -- Fire map-enter scene script once the warp settles. if self.pendingSceneScript and not self:busy() then self.pendingSceneScript = false if self:trySceneScript() then return end end -- CheckTimeEvents, from the player-event chain: the Bug Contest clock is the -- only thing it polls while the contest is up, and its carry is a script, so -- it goes above the input gate and below the one that says a script is -- already running. if not self:busy() and self:checkTimeEvents() then return end -- Freeze player input while a script / textbox / cutscene move is up. if self:busy() then -- Keep scripted entities animating mid-step. if self.player and self.player.moving then self:playerStepGrass() if self.player:update() then self.player.inGrass = self:grassAt(self.player.cellX, self.player.cellY) end end self:updatePeople() return end local p = self.player self:playerStepGrass() local landed = p:update() -- CopyCoordsTileToLastCoordsTile -> SetTallGrassFlags, which is what a step -- ENDS on (engine/overworld/map_objects.asm:196-208, :247). if landed then p.inGrass = self:grassAt(p.cellX, p.cellY) end -- CheckTrainerEvent is PlayerEvents' FIRST test (engine/overworld/events.asm: -- 245) and, unlike every arm of CheckTileEvent, it is not behind -- wEnabledPlayerEvents: MapEvents clears that byte on every pass -- (events.asm:168) and CheckPlayerState only re-sets it on a step that -- actually landed (events.asm:210-221). So the sight cone is sampled EVERY -- overworld frame -- a spinner that rotates onto a standing player engages, -- and a sighting that arrived while a script held the world fires the frame -- the script ends. It stays after p:update(), which is what commits -- cellX/cellY, and above the `landed` block so the cart's CheckTrainerEvent -- before CheckTileEvent (events.asm:249) ordering survives: a trainer whose -- line crosses a warp or a coord-event tile wins, as on hardware. if self:checkTrainerBattle() then return end if landed then -- hot path: the payload is only built when something is listening, exactly -- as OverworldState:onStepComplete guards it under Gen 1. `tile` is the -- COLLISION byte here -- Gold's map has no per-cell tile id, and the -- collision byte is the value every one of the engine's own step tests -- reads -- and `daytime` is the palette set beside Gen 1's `tod`. if Runtime.wants("world.stepped") then Runtime.emit("world.stepped", { mapId = self.map.id, x = p.cellX, y = p.cellY, tile = self.map:cellCollision(p.cellX, p.cellY), tod = self.tod, daytime = self.daytime, }) end self:clearWarpCooldownIfLeft() if self:checkWarpOnArrive() then return end if not self.map then return end if self:tryCoordScript() then return end -- CheckTileEvent's own order: the coord events, then CountStep, then -- RandomEncounter. A carry out of CountStep queues a player event, so the -- step that hatches an egg or drops a poisoned mon never also walks into a -- wild battle. if self:countStep() then return end -- Grass rolls after the warp and coord checks, so stepping onto a door -- inside grass still warps rather than starting a battle. if self:tryWildEncounter() then return end end -- People keep their anim paths even while the player is idle / mid-step. self:updatePeople() -- .CheckForced / CheckStandingOnIce: while the tile underfoot is ice and a -- prior step latched .FinishFacing, THIS frame's d-pad is forced to that -- direction so one press slides until a non-ice landing or a bump. The -- override is local -- writing it into heldDir would survive onto the floor -- after the slide (pollInput is what refreshes heldDir from real input) and -- keep the player walking. StandInPlace clears the latch when idle off ice. -- -- .CheckTile runs ABOVE both of those, and its HI_NYBBLE_CURRENT arm is -- stronger than either: a $3x tile underfoot picks the direction outright, -- with no d-pad and no latch involved. On COLL_WATERFALL $33 that is one -- DOWN per frame, which is both the automatic plunge and the reason a -- waterfall column cannot be climbed by walking into it -- HM07's own climb -- is a scripted step under World:busy, which returns above this line. local dir = self.heldDir if not p.moving then local coll = self:playerCollision() local current = Permissions.currentDirection(coll) or Permissions.doorForcedDirection(coll) if current then dir = current elseif self.turningDirection and Permissions.isIce(coll) then dir = self.turningDirection elseif not dir then self.turningDirection = nil end end if not dir then p.turnArmed = true return end if p.moving then return end if self:checkCarpetWhileStanding() then return end local result = self:movePlayer(dir) if result == "edge" then self:tryConnection(dir) elseif result == "blocked" and p.facing == dir then -- .CheckNPC came back 2: something movable is in the way. The step is -- lost either way, and the boulder is what moves. local d = Map.DELTA[dir] self:tryPushBoulder(dir, p.cellX + d[1], p.cellY + d[2]) end end function World:pollInput(input) -- DoPlayerMovement .GetDPad: a DOWNHILL map with no direction held reads as -- DOWN, which is the Cycling Road rolling the player along on its own. self.heldDir = Bike.forcedDirection(heldDirection(input), self:downhill()) end function World:zoomStep(delta) Zoom.step(delta, self:fitScale()) self:rebuildNeighbors() self:rebuildPeople({ seamless = true }) end function World:zoomCycle() Zoom.cycle(self:fitScale()) self:rebuildNeighbors() self:rebuildPeople({ seamless = true }) end -- The world pass, split in two because TILT projects only one of them: the -- ground (the neighbor strips and this map) goes onto the perspective plane, -- while everything standing on it draws upright. function World:drawGround(s) local G = love.graphics local cam = self.camera G.setColor(1, 1, 1, 1) -- The border block first, tiled over the whole view: the map canvas is -- exactly the map's own blocks, so on anything smaller than the viewport -- (GOLDENROD_DEPT_STORE_ELEVATOR is 2x2) the rest of the screen was the -- clear colour. LoadMetatiles fills it with wMapBorderBlock instead, and -- the connection strips and the map draw straight over the top of it. if self.map then -- Destination size must be the CURRENT canvas (tilt grows it past the -- window). getDimensions() is always the window, so a grown tilt capture -- used to tile the void against the wrong view and the fill drifted off -- the map grid as the camera moved. local canvas = G.getCanvas() local bw, bh if canvas then bw, bh = canvas:getDimensions() else bw, bh = G.getDimensions() end BorderFill.draw(self, self:borderImageFor(self.map.id), cam.x, cam.y, bw, bh, s, self.map.id) end for _, nb in ipairs(self.neighbors) do G.draw(nb.image, math.floor((nb.ox - cam.x) * s), math.floor((nb.oy - cam.y) * s), 0, s, s) self:drawAnimCells(nb.id, nb.ox, nb.oy, s) end G.draw(self.mapImage, math.floor((0 - cam.x) * s), math.floor((0 - cam.y) * s), 0, s, s) -- _AnimateTileset's VRAM writes, as an overlay: the map canvas is baked -- once and the four water / two flower frames go over the top of it -- (engine/tilesets/tileset_anims.asm:167, :197). self:drawAnimCells(self.map and self.map.id, 0, 0, s) end -- Everyone on the map, Y-sorted with the player, plus the emote bubble over -- the top -- it is an OBJ at OAM priority on the cart, and nothing walks in -- front of it in the half-second it is up. -- -- `billboard` is nil on the flat path. With TILT on it is a function that -- takes a foot point in flat screen pixels and a draw callback, and slides the -- draw onto that point's projection: only the ground tilts, so a standing -- thing stays upright and unscaled and the one thing that moves is its anchor. function World:drawPeople(s, billboard) local G = love.graphics local p = self.player local cam = self.camera local drawList = { { kind = "player", py = p.py, ox = 0, oy = 0 }, } for _, npc in ipairs(self.npcs) do drawList[#drawList + 1] = { kind = "npc", npc = npc, ox = 0, oy = 0, py = npc.py, } end for _, g in ipairs(self.ghosts) do drawList[#drawList + 1] = { kind = "npc", npc = g.npc, ox = g.ox, oy = g.oy, py = g.oy + g.npc.py, } end table.sort(drawList, function(a, b) return a.py < b.py end) for _, entry in ipairs(drawList) do local ox = math.floor((entry.ox - cam.x) * s) local oy = math.floor((entry.oy - cam.y) * s) local entity = entry.kind == "player" and p or entry.npc local function body() if entry.kind == "player" then self.player:draw(ox, oy, s) else entry.npc:draw(ox, oy, s) end -- ShakeGrass rustle only. The cart also ORs OAM_PRIO onto the lower -- 16x8 (drawGrassOver / IN_GRASS) so the BG tuft covers the feet, but -- stacking that plain grass tile on top of the character with the -- rustle reads as a double overlay here -- keep the walk-through anim. -- Only the current map's own entities: a ghost's cells belong to a -- neighbour's block list. if entry.ox == 0 and entry.oy == 0 then self:drawGrassShake(entity, ox, oy, s) end end if billboard then -- The foot is the baseline centre of the sprite's own cell. billboard(ox + (entity.px + 8) * s, oy + (entity.py + 16) * s, body) else body() end end self:drawEmote(s, billboard) self:drawHealAnim(s, billboard) end -- Split out of drawPeople so World:drawPipeline composites the one copy the -- flat and tilt paths draw, not a second transcription of it. function World:drawEmote(s, billboard) if not (self.emote and self.emote.image) then return end local G = love.graphics local cam = self.camera local e = self.emote local ex = math.floor((e.entity.px - cam.x) * s) local ey = math.floor((e.entity.py - 16 - cam.y) * s) -- SpawnEmote.EmoteObject (engine/overworld/map_objects.asm:2029) spawns the -- bubble as an OBJ on PAL_OW_EMOTE, which LoadMapPals resolves to the -- "silver" row of gfx/overworld/npc_sprites.pal (white / white / RGB -- 13,13,13 / black). That row is byte-identical in all four daytime -- blocks, so the bubble is the same at any hour, but it still goes through -- the daytime lookup because that is what LoadMapPals does and it keeps the -- emote on the same path as every other OW sprite. Blitting the extracted -- sheet raw left the interior at the DMG ramp's shade 1 (170 grey) instead -- of white: the Gen 2 repeat of #505. local emoteColors = Palettes.spritePalette(self.palettes, self.daytime or Palettes.daytimeFor(self.map and self.map.def, self:hour(), self.flashUsed), { paletteId = 5 }) local function blit() G.setColor(1, 1, 1, 1) G.draw(e.image, ex, ey, 0, s, s) end local function body() -- GbcPalette.with, not useRaw: the DMG and CLASSIC colour modes still -- have to collapse the row to their own ramps, and it restores whatever -- shader the billboard pass had set rather than assuming none. if emoteColors and GbcPalette.available() then GbcPalette.with(emoteColors, blit) else blit() end end if billboard then billboard(ex + 8 * s, ey + 32 * s, body) else body() end end function World:drawWorldBody(s) self:drawGround(s) self:drawPeople(s) end -- Gold's half of the world-pipeline seam: same ctx keys, same order and the -- same nil-falls-back-to-2D rule as src/world/OverworldController.lua:4867. function World:drawPipeline(id, w, h, s) local G = love.graphics local cam = self.camera local ctx = { state = self, cam = cam, vw = self.viewW, vh = self.viewH, -- No BG-only shake here: World:draw slides the whole frame through -- camera.y, so the ground row IS the camera row. bgY = cam.y, width = w, height = h, scale = s, level = Pipelines.level(id), -- imageFor keys its bakes by GbcPalette.mode, so the colour is already in -- the art: nil, like Gen 1 returns in its true-colour modes. paletteFor = function() return nil end, spriteColors = function() return nil end, -- Gold's only standing effects; it has no dust/cutTree/bird/rod overlay, -- and Gen 1's `at` skips a nil body, so those keys are simply absent. fx = { emote = function() self:drawEmote(1, nil) end, heal = function() self:drawHealAnim(1, nil) end, }, } -- `project(wx, wy)` -> canvas pixels, nil behind the camera. s = 1 lays the -- closures out in world pixels off the flat foot, the unit Gen 1 uses. ctx.drawFx = function(project, scale) scale = scale or s local function at(fx, fy, body) local sx, sy = project(fx + cam.x, fy + cam.y) if not sx then return end -- behind the camera G.push() G.scale(scale, scale) G.translate(sx / scale - fx, sy / scale - fy) body() G.pop() end self:drawEmote(1, at) self:drawHealAnim(1, at) end local override = Pipelines.drawWorld(id, ctx) -- world post-processes fold in here, so they never touch the text box on top if override then override = Pipelines.worldPresent(override, ctx) end return override end -- The perspective quad TILT draws the ground onto. The shader and the -- 4-vertex mesh are the renderer's -- the projection is the same one the Gen 1 -- world pass uses, so there is no reason for a second copy of either. function World:tiltMesh() local Renderer = require("src.render.Renderer") local shader = Renderer.tiltShader and Renderer:tiltShader() local mesh = Renderer.tiltMesh and Renderer:tiltMesh() if not (shader and mesh) then return nil end return mesh, shader end -- `gw, gh` are the grown capture size from World:draw (Tilt.viewGrowth), -- matching Renderer:worldViewSize. Camera is already followed for that view. function World:drawTilted(w, h, s, gw, gh) local mesh, shader = self:tiltMesh() if not mesh then self:drawWorldBody(s) return end local G = love.graphics gw = gw or w gh = gh or h -- Linear sampling on the tilt canvas softens the shimmer the perspective -- warp would otherwise put on every pixel edge; the flat path keeps nearest. if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= gw or self.tiltCanvas:getHeight() ~= gh then if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end self.tiltCanvas = G.newCanvas(gw, gh) self.tiltCanvas:setFilter("linear", "linear") end local previous = G.getCanvas() G.setCanvas(self.tiltCanvas) G.clear(0, 0, 0, 0) -- A canvas does not reset the transform, so anything drawn into one from -- inside a draw call needs push()/origin() around it. G.push() G.origin() self:drawGround(s) G.pop() G.setCanvas(previous) mesh:setTexture(self.tiltCanvas) mesh:setVertices(Tilt.meshCorners(gw, gh)) G.push() G.translate((w - gw) / 2, (h - gh) / 2) G.setColor(1, 1, 1, 1) G.setShader(shader) G.draw(mesh) G.setShader() G.pop() -- ...and the standing things over it, each translated from its flat foot -- onto that foot's projection. Nothing here is sheared or resized: tilt -- changes where a sprite stands, not what it looks like. self:drawPeople(s, function(fx, fy, body) -- The ground quad carries the flat canvas and nothing else, so a foot -- outside it has no ground under it; drawing it anyway put NPCs from two -- screens away over the border fill, where the map stops being drawn. if not Tilt.onGround(fx, fy, gw, gh, 32 * s) then return end local sx, sy = Tilt.groundPoint(fx, fy, gw, gh) G.push() G.translate(sx - fx + (w - gw) / 2, sy - fy + (h - gh) / 2) body() G.pop() end) end -- The COLOR option can change under a standing world (the hotkey, or the -- OPTION screen closing), and the map is a baked canvas rather than a live -- draw -- so the cached references have to be re-fetched when it does. The -- bakes themselves are keyed by mode in imageFor, so this is a pointer swap -- after the first time each mode is seen, not a re-bake. function World:refreshColorMode() local mode = GbcPalette.mode if self.colorMode == mode then return end self.colorMode = mode if not self.map then return end self.mapImage = self:imageFor(self.map.id) self:rebuildNeighbors() end function World:draw() local G = love.graphics local w, h = G.getDimensions() self:refreshColorMode() G.clear(0.07, 0.05, 0.02, 1) if not self.mapImage or not self.player then G.setColor(0.85, 0.57, 0.13, 1) G.printf("POKEMON GOLD", 0, math.floor(h * 0.38), w, "center") G.setColor(0.92, 0.90, 0.82, 1) G.printf(self.status or "No map.", 0, math.floor(h * 0.48), w, "center") G.printf("Press Escape to quit.", 0, math.floor(h * 0.62), w, "center") G.setColor(1, 1, 1, 1) return end local s = self:zoomScale() -- Decided before sizing the view: a world pipeline wins over tilt, and tilt -- grows the capture the way Renderer:worldViewSize does on Gen 1 so the -- camera, BorderFill and tilt canvas all share one grid. local pipelineId = Pipelines.worldPipeline() local tilt = (not pipelineId) and Tilt.active() and self:tiltMesh() ~= nil local gw, gh = w, h if tilt then local g = Tilt.viewGrowth() gw, gh = math.ceil(w * g), math.ceil(h * g) end local vw = math.ceil(gw / s) local vh = math.ceil(gh / s) if vw % 2 ~= 0 then vw = vw + 1 end if vh % 2 ~= 0 then vh = vh + 1 end if vw ~= self.viewW or vh ~= self.viewH then self:rebuildNeighbors() self:rebuildPeople({ seamless = true }) end self.viewW, self.viewH = vw, vh local p = self.player self.camera:follow(p.px, p.py, vw, vh) -- StepFunction_ScreenShake adds its offset to wPlayerStepVectorY, i.e. the -- whole frame slides vertically while the ground stays put underneath. if self.shake then self.camera.y = self.camera.y + (self.shake.phase or 0) end local override = pipelineId and self:drawPipeline(pipelineId, w, h, s) or nil -- TILT projects the finished world frame, so with it on the map, people and -- emote go into a canvas first and that canvas is drawn as a perspective -- quad. Everything after -- the encounter pic and the survey HUD -- stays -- flat, the same split the Gen 1 renderer makes; a pipeline's finished image -- lands in exactly the same place. if override then G.setColor(1, 1, 1, 1) G.draw(override, 0, 0) elseif tilt then self:drawTilted(w, h, s, gw, gh) else self:drawWorldBody(s) end -- Pokepic (engine/events/pokepic.asm:1-28): MenuBox at the header's coords, -- then the padded 7x7 frontpic at top+1, left+1. UI, so fitScale not zoom. if self.pokePic then local sPic = self:fitScale() local pw = self.pokePic:getDimensions() local pad = POKEPIC.pad[math.floor(pw / 8)] or POKEPIC.pad[7] G.push() G.translate(math.floor((w - 160 * sPic) / 2), math.floor((h - 144 * sPic) / 2)) G.scale(sPic, sPic) G.setColor(1, 1, 1, 1) local function body() Font.drawBox(POKEPIC.left, POKEPIC.top, POKEPIC.w, POKEPIC.h) G.draw(self.pokePic, (POKEPIC.left + 1 + pad[1]) * 8, (POKEPIC.top + 1 + pad[2]) * 8) end if self.pokePicColors then GbcPalette.with(self.pokePicColors, body) else body() end G.pop() G.setColor(1, 1, 1, 1) end -- engine/events/poisonstep_pals.asm:9-42 if self.poisonFlash and self.poisonFlash > 0 then self.poisonFlash = self.poisonFlash - 1 if GbcPalette.mode == "gbc" then G.setColor(28 / 31, 21 / 31, 1, 0.55) else G.setColor(0, 0, 0, 0.45) end G.rectangle("fill", 0, 0, w, h) G.setColor(1, 1, 1, 1) end -- FadeOutToWhite / FadeOutToBlack, held until a FadeInFrom* clears it. On -- the cart the pair brackets a scripted cutscene's set change (the Elite Four -- doors, the Radio Tower takeover, Lugia's chamber); the port has no -- palette-cycle fade, so the honest stand-in is the flat sheet the cart's own -- fade ends on, over the world and under the text box the script is running. if self.fade then -- fadeLevel is the map setup chain's four-step ramp; a fade special sets it -- to 1 because RotateThreePalettes* has already finished by the time the -- script that called it runs on. local a = self.fadeLevel or 1 if self.fade == "white" then G.setColor(1, 1, 1, a) else G.setColor(0, 0, 0, a) end G.rectangle("fill", 0, 0, w, h) G.setColor(1, 1, 1, 1) end -- The survey overlay is a developer aid, not part of the game: POKEPORT_DEV -- (or the F3 toggle) shows it, a normal boot does not. if self.showDebugHud then G.setColor(0.85, 0.57, 0.13, 1) G.printf("POKEMON GOLD", 0, 10, w, "center") G.setColor(0.92, 0.90, 0.82, 1) local label = string.format("%s (%d,%d) %s · %s · zoom %s", self.map.id, p.cellX, p.cellY, p.facing, tostring(self.daytime), Zoom.offsetLabel(Zoom.offset)) G.printf(label, 0, 28, w, "center") G.printf("Arrows move · wheel/-/= zoom · 4 cycle zoom · Escape quits", 0, h - 28, w, "center") G.setColor(1, 1, 1, 1) end end -- exported for the Gen 1 FieldDefaults facade (src/mods/Gen2Compat.lua) -- rather than duplicated there World.PLAYER_SPRITE = PLAYER_SPRITE return World