Files
gen1recomp/src/script/gen2/Vm.lua
T

2656 lines
132 KiB
Lua

-- Gen 2 script VM over import-extracted command lists (data/generated/scripts.lua).
-- Yields on text / yesorno / movement / waitsfx so the overworld can drive UI.
local Movement = require("src.script.gen2.Movement")
local Opcodes = require("src.script.gen2.Opcodes")
local Specials = require("src.script.gen2.Specials")
local Strings = require("src.core.Strings")
local Logger = require("src.core.Logger")
-- The mod event/hook buses. Null objects until a loader installs the live
-- ones, so every emit/call site below is a safe pass-through on a headless or
-- mod-free boot, and the Runtime.wants* guards keep even the payload
-- construction off that path.
local Runtime = require("src.mods.Runtime")
local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility
local Vm = {}
Vm.__index = Vm
-- The op name a mod's row carries; no cart byte decodes to it. See
-- src/script/gen2/Opcodes.lua and Vm:runModCommand.
local MOD_COMMAND = Opcodes.MOD_COMMAND
local function arg1(cmd)
if cmd == nil then return nil end
if cmd.args then return cmd.args[1] end
return nil
end
-- constants/script_constants.asm. LAST_TALKED is -2, so the byte the
-- extractor writes is 254: `disappear LAST_TALKED` means hLastTalked, not
-- object 254. Script_disappear / Script_turnobject / Script_writeobjectxy all
-- carry that substitution; Script_appear pointedly does not.
local LAST_TALKED = 0xfe
-- CompareMoneyAction writes one of these three to wScriptVar, and HAVE_MORE is
-- ZERO: an `iffalse` after a checkmoney / checkcoins means the player has MORE
-- than the amount asked for, not less. Getting that round the wrong way is the
-- whole of the Goldenrod coin vendor.
local HAVE_MORE, HAVE_AMOUNT, HAVE_LESS = 0, 1, 2
-- CheckPokeMail's five answers; REFUSED is what SelectMonFromParty's carry
-- produces when the player backs out, and so also what a driver with no mail
-- handler at all gets. The model is src/core/gen2/Mail.lua.
local POKEMAIL_REFUSED = 2
-- Script_askforphonenumber's three answers. SUCCESS IS ZERO here.
local PHONE_CONTACT_GOT, PHONE_CONTACTS_FULL, PHONE_CONTACT_REFUSED = 0, 1, 2
-- constants/misc_constants.asm: the caps GiveMoney and GiveCoins write back.
local MAX_MONEY, MAX_COINS = 999999, 9999
-- constants/sfx_constants.asm
local SFX_ITEM, SFX_HANG_UP = 0x01, 0x6b
-- constants/script_constants.asm: EMOTE_FROM_MEM is -1, i.e. the byte $ff.
local EMOTE_FROM_MEM = 0xff
-- constants/misc_constants.asm GS_VERSION: 0 Gold, 1 Silver.
local GS_VERSION_GOLD = 0
-- engine/overworld/variables.asm .VarActionTable rows for wMapGroup and
-- wMapNumber. `readvar` is the only route this VM has to either, and the pair
-- is how the CART names a map (constants/map_constants.asm map_const): see
-- Vm:scriptCtx, which reports them to mods. Same ids World:readVar answers on.
local VAR_MAPGROUP, VAR_MAPNUMBER = 0x0c, 0x0d
-- wBattleResult (constants/battle_constants.asm), which is what `startbattle`
-- leaves in wScriptVar. A win is ZERO: see the command for the scripts that
-- depend on it. DRAW is the Sudowoodo / Gyarados "it fled" case; no battle
-- resumes with it yet, so it is here for the branch rather than for a caller.
local BATTLE_RESULTS = { win = 0, lose = 1, draw = 2 }
-- ItemPocketNames (data/items/pocket_names.asm), indexed by the item type
-- CheckItemPocket leaves in wItemAttributeValue: ITEM 1, KEY_ITEM 2, BALL 3,
-- TM_HM 4 (constants/item_data_constants.asm:15-19). GetPocketName copies the
-- matching string into wStringBuffer3, which is the SECOND blank in both
-- _PutItemInPocketText and _PocketIsFullText (data/text/common_2.asm:1351,
-- :1361). The cache's items.lua carries the same four names on `pocket`.
local POCKET_NAMES = {
ITEM = "ITEM POCKET",
KEY_ITEM = "KEY POCKET",
BALL = "BALL POCKET",
TM_HM = "TM POCKET",
}
-- CompareMoney (engine/events/money.asm) reports account minus amount as one
-- of the HAVE_* three rather than as a boolean.
local function compareFunds(account, amount)
if account < amount then return HAVE_LESS end
if account == amount then return HAVE_AMOUNT end
return HAVE_MORE
end
-- givemoney / takemoney / checkmoney lay their operand down as `db account`
-- followed by `bigdt money` (macros/scripts/events.asm), and the extractor
-- leaves that untouched in `args`: args[1] is the account, args[2..4] are the
-- three money bytes BIG-endian.
local function moneyArgs(cmd)
local a = cmd.args or {}
return a[1] or 0,
(a[2] or 0) * 0x10000 + (a[3] or 0) * 0x100 + (a[4] or 0)
end
-- The plain `dw` operands (coins, WRAM addresses, menu headers, mail and call
-- pointers) are little-endian in ROM whatever order the routine that reads them
-- stores them in: LoadCoinAmountToMem flips its two bytes into hMoneyTemp, but
-- the value in the script is still args[1] + args[2] * 256.
local function wordArg(cmd, first)
local a = cmd.args or {}
first = first or 1
return (a[first] or 0) + (a[first + 1] or 0) * 0x100
end
-- The operand list of a MOD's row (Vm:runModCommand): `args` as the row wrote
-- it, or the tail of a Gen 1 shaped row -- { "mymod:shake", 4, 2 } -> { 4, 2 }.
-- Built fresh per dispatch rather than cached on the row, because the row
-- belongs to the mod and this VM does not write to other people's tables.
local function modArgs(cmd)
if cmd.args then return cmd.args end
local out = {}
for i = 2, #cmd do out[i - 1] = cmd[i] end
return out
end
-- Execute ONE command out of a list. Split out of runList so the
-- `script.command` mod hook below has a single function to wrap, exactly the
-- way src/script/ScriptRunner.lua:163-170 wraps its own one-row dispatch.
--
-- Returns, in the same vocabulary the Gen 1 runner's control commands use:
-- "end" this command ends the list it is in -- `end`, a tail-call
-- `sjump`, a taken `iftrue`, a `jumptext` that never comes back
-- number a 1-based row to continue at; only a mod hook produces one, the
-- cart's own control flow is all whole-list tail calls
-- nil fall through to the next row
local runList
local function runCmd(self, cmd, op)
if op == "end" or op == "endall" or op == "endcallback" or op == "reloadend" then
return "end"
elseif op == "sjump" or op == "stopandsjump" then
runList(self, cmd.script)
return "end"
elseif op == "scall" then
runList(self, cmd.script)
elseif op == "sdefer" then
-- Script_sdefer only RECORDS the target: it writes
-- wDeferredScriptBank/Addr and sets RUN_DEFERRED_SCRIPT, and it is
-- RunSceneScript (engine/overworld/events.asm:388) that picks it up --
-- after ScriptEvents has run the scene body to its `end`, as that pass's
-- player event. Running it inline instead put the League walk-ins, the
-- Cerulean grunt and the Mt Moon rival cutscene inside the scene script,
-- i.e. a beat early and under the map's fade-in. Vm:runDeferred drains
-- it where the cart does.
self.deferred = cmd.script
elseif op == "farscall" then
-- Script_farscall is Script_scall with the bank taken from the command
-- rather than from wScriptBank; both fall into the same ScriptCall, which
-- pushes wScriptBank/wScriptPos and comes BACK here on `end`. The
-- extractor has already resolved bank:addr into a scripts.lua key and
-- queued the target for disassembly, so at this level the two are one
-- call. An unresolved key is a no-op: runList returns on a nil list.
runList(self, cmd.script)
elseif op == "farsjump" then
-- Script_farsjump is Script_sjump across banks: ScriptJump overwrites
-- wScriptBank/wScriptPos and never pushes a return, so nothing after it
-- in this list runs. Opcodes.TERMINATORS already stops the extractor
-- there; returning is what makes the interpreter agree with it.
runList(self, cmd.script)
return "end"
elseif op == "memjump" then
-- Script_memjump reads a three-byte far pointer OUT OF WRAM at the
-- address in args = {lo, hi} and jumps to it. The only real user is
-- engine/overworld/events.asm's `memjump wQueuedScriptBank`, filled in at
-- run time by LoadMemScript, so there is no static target for the
-- extractor to follow and nothing here to run.
--
-- It is still a JUMP: the cart does not come back to this list. So this
-- returns rather than falling through, which is the difference between
-- ending the script cleanly and running whatever bytes sit after it.
-- Script_memjump never touches wScriptVar, so neither does this.
return "end"
elseif op == "memcall" then
-- Script_memcall reads a three-byte far pointer out of WRAM at
-- args = {lo, hi} and calls it. The pointer is written at run time by
-- the phone engine (engine/phone/phone.asm: `memcall wPhoneScriptBank`,
-- `memcall wCallerContact + PHONE_CONTACT_SCRIPT2_BANK`), so nothing
-- static exists for the extractor to follow and there is no target to
-- interpret. An explicit no-op rather than a silent skip; like the
-- cart's own Script_memcall it leaves wScriptVar alone.
elseif op == "memcallasm" then
-- Script_memcallasm: the same WRAM far pointer as memcall, but `rst
-- FarCall`ed as raw Game Boy code instead of as script bytecode. Only
-- engine/overworld/events.asm's `memcallasm wQueuedScriptBank` uses it.
-- No interpreter can honour that, so it is an explicit no-op. All 34
-- rows the extractor found sit in data regions mis-read as code.
elseif op == "callasm" then
-- Script_callasm: `rst FarCall` into raw Game Boy code at bank:addr
-- (args = {bank, lo, hi}). Nothing in this port can run that, so this is
-- a no-op, but a LOUD one: the routine a real callasm reaches is usually
-- the one that writes wScriptVar for the iffalse right after it.
-- FindItemInBallScript is `callasm .TryReceiveItem / iffalse .no_room`,
-- FruitTreeScript is `callasm CheckFruitTree / iffalse .fruit`.
--
-- Script_callasm itself never touches wScriptVar, so neither does this:
-- inventing a 0 or a 1 here would pick a branch at random for every
-- caller at once. The hook is the seam instead. No `callasm` exists
-- anywhere under pokegold/maps/, so nothing reachable today waits on it.
if self.callAsmFn then
-- cmd.label appears the day the importer resolves bank:addr through
-- pokegold-symbols/pokegold.sym; until then the hook gets nil for it
-- and can still match on the bank/address pair. A number back is
-- wScriptVar, nil means "not modelled, leave it alone".
local value = self.callAsmFn(cmd.label, arg1(cmd) or 0, wordArg(cmd, 2))
if value ~= nil then self.scriptVar = value % 256 end
end
elseif op == "jumptext" then
self:emitFace(false)
self:showText(cmd.text)
return "end"
elseif op == "jumptextfaceplayer" then
self:emitFace(true)
self:showText(cmd.text)
return "end"
elseif op == "faceplayer" then
self:emitFace(true)
elseif op == "opentext" or op == "closetext"
or op == "promptbutton" or op == "closepokepic" then
-- UI framing / pokepic teardown handled by hooks or TextBox.
if op == "closepokepic" then
-- Script_closepokepic is CloseWindow on the window Script_pokepic
-- opened, so the pic-window flag `waitbutton` reads goes down with it.
self.picOpen = false
if self.hidePicFn then self.hidePicFn() end
end
elseif op == "reanchormap" then
-- Script_reanchormap calls ReanchorMap (home/window.asm), which is
-- ClearWindowData plus a re-blit of the BG map from the player's current
-- position. The re-blit half is free here -- src/render/Camera.lua
-- follows the player every frame rather than anchoring a scrolled map --
-- but the window teardown is not: WillsRoom's walk-in reanchors between
-- the applymovement and the earthquake, and ElmsLab reanchors before
-- every `pokepic`, so a window still standing here is one the cart has
-- already taken down.
self.picOpen = false
if self.hidePicFn then self.hidePicFn() end
elseif op == "writetext" or op == "farwritetext" then
self:showText(cmd.text)
if self.nextOp == "playsound" then
-- pokegold home/joypad.asm PromptButton: the real press this box's
-- own close absorbed plays SFX_READ_TEXT_2; drain it before the
-- script's own playsound or Sound.lua's priority gate drops it.
coroutine.yield({ kind = "waitsfx" })
end
elseif op == "rawtext" then
-- NOT a cart opcode. `writetext`'s operand is a KEY into text.lua, and
-- text.lua only holds strings the extractor reached through a script
-- pointer -- so a hand-ported script (src/world/gen2/CmdQueue.lua's two
-- stone tables, whose text hangs off a callback nothing walks) has a
-- literal and no key to name it by. This is the one command that carries
-- one, and the extractor never emits it.
-- Declared through Strings.source at the table, looked up here, which is
-- the split src/core/Strings.lua asks for: a module-level template must
-- not resolve before Strings.load has a catalog.
-- `stay` rides the row for the same reason the text does: a transcription
-- that holds ONE MapTextbox over the next few commands has to say so, and
-- Vm:textStays' one-command lookahead cannot work it out.
-- `stay` / `hold` ride the row for the same reason the text does: a
-- transcription that keeps ONE MapTextbox standing over the next few
-- commands has to say so, and Vm:textStays' one-command lookahead cannot
-- work it out. `hold` is the cart `pause` those commands contain, in
-- Script_pause's own doubled frames (Vm:pauseFrames).
self:showRaw(Strings(cmd.text), cmd.stay,
cmd.hold and Vm.pauseLength(cmd.hold) or nil)
elseif op == "waitbutton" then
-- Script_waitbutton (engine/overworld/scripting.asm) is WaitButton, i.e.
-- WaitPressAorB_BlinkCursor: a REAL press of A or B, not a frame count.
--
-- After a `writetext` the port's TextBox has already taken that press on
-- the last page, so the ordinary `writetext / waitbutton / closetext` run
-- must not ask for a second one -- that was the whole of the old no-op.
-- The case it got wrong is the one with no text box under it: a `pokepic`
-- window. ElmsLab's three starter balls are `reanchormap / pokepic /
-- cry / waitbutton / closepokepic / opentext / writetext / yesorno`
-- (maps/ElmsLab.asm ElmsLabPokeBallScript), and there the press is the
-- ONLY thing holding the pic up: skipping it ran pokepic and closepokepic
-- inside a single Vm:resume, so the starter's pic was built and thrown
-- away without one frame ever drawing it (#911).
if self.picOpen and self.waitButtonFn then
coroutine.yield({ kind = "waitbutton" })
end
elseif op == "checkevent" then
self.scriptVar = self.events:get(cmd.event) and 1 or 0
elseif op == "setevent" then
self.events:set(cmd.event, true)
if self.onFlagsChanged then self.onFlagsChanged() end
elseif op == "clearevent" then
self.events:set(cmd.event, false)
if self.onFlagsChanged then self.onFlagsChanged() end
elseif op == "checkflag" then
-- Script_checkflag: EngineFlagAction CHECK_FLAG over an ENGINE_* id,
-- result in c, wScriptVar TRUE only when c is non-zero. The read half of
-- the setflag / clearflag pair below; see setflag for why the ENGINE_*
-- namespace is kept apart from setevent's wEventFlags.
local flag = cmd.flag or wordArg(cmd)
local set
if self.getEngineFlagFn then
set = self.getEngineFlagFn(flag)
else
set = self.engineFlags[flag]
end
self.scriptVar = set and 1 or 0
elseif op == "setflag" or op == "clearflag" then
-- Script_setflag / Script_clearflag: EngineFlagAction SET_FLAG or
-- RESET_FLAG over an ENGINE_* id (constants/engine_flags.asm). This is a
-- DIFFERENT namespace from setevent's wEventFlags and the two must not
-- share a store: engine flags are the eight Johto and eight Kanto badges,
-- the Pokegear cards, ENGINE_POKEDEX, ENGINE_BUG_CONTEST_TIMER and the
-- fly points, and EngineFlags scatters each one into its own byte
-- (wJohtoBadges, wPokegearFlags, wStatusFlags, wVisitedSpawns...).
--
-- They never gate object visibility, which is wEventFlags' job, so this
-- deliberately does NOT call onFlagsChanged and cannot make an NPC pop in
-- mid-script. BugContestResultsWarpScript's `clearflag
-- ENGINE_BUG_CONTEST_TIMER` is what stops the contest clock, so a missing
-- clearflag leaves that timer running forever. The local table is the
-- fallback for a VM built without the hook (tests); with the hook, the
-- badge lands in the save.
local flag = cmd.flag or wordArg(cmd)
local value = (op == "setflag")
if self.setEngineFlagFn then
self.setEngineFlagFn(flag, value)
else
self.engineFlags[flag] = value or nil
end
elseif op == "iftrue" then
if self.scriptVar ~= 0 then
runList(self, cmd.script)
return "end"
end
elseif op == "iffalse" then
if self.scriptVar == 0 then
runList(self, cmd.script)
return "end"
end
elseif op == "ifequal" then
if self.scriptVar == (cmd.value or 0) then
runList(self, cmd.script)
return "end"
end
elseif op == "ifnotequal" then
if self.scriptVar ~= (cmd.value or 0) then
runList(self, cmd.script)
return "end"
end
elseif op == "ifgreater" then
-- Script_ifgreater: `ld a, [wScriptVar] / ld b, a / GetScriptByte / cp b`.
-- The compare is OPERAND minus wScriptVar, so carry (and the jump) is set
-- when the operand is BELOW wScriptVar: the branch is taken on
-- scriptVar > value. Spelled out because getting it round the wrong way
-- silently swaps both arms. Unsigned byte compare, which is why addval
-- wraps at 256. Like ifequal, the jump is a tail call.
if (self.scriptVar or 0) > (cmd.value or 0) then
runList(self, cmd.script)
return "end"
end
elseif op == "ifless" then
-- Script_ifless: the operand goes into b FIRST, then
-- `ld a, [wScriptVar] / cp b`, i.e. scriptVar minus operand, so carry
-- (and the jump) means scriptVar < value. Note the operand order is the
-- reverse of ifgreater's: the two are not mirror images in the source and
-- copying one into the other is how the polarity gets lost.
if (self.scriptVar or 0) < (cmd.value or 0) then
runList(self, cmd.script)
return "end"
end
elseif op == "pause" then
self:pauseFrames(cmd.frames or cmd.length or 0)
elseif op == "setscene" then
local scene = cmd.scene or arg1(cmd) or 0
if self.setSceneFn then self.setSceneFn(scene) end
elseif op == "checkscene" then
self.scriptVar = self.getSceneFn and self.getSceneFn() or 0
elseif op == "setmapscene" then
local group = cmd.group or (cmd.args and cmd.args[1])
local map = cmd.map or (cmd.args and cmd.args[2])
local scene = cmd.scene or (cmd.args and cmd.args[3]) or 0
if self.setMapSceneFn then self.setMapSceneFn(group, map, scene) end
elseif op == "checkmapscene" then
-- Script_checkmapscene: GetMapSceneID for the `group, map` pair (first
-- byte group, second byte map, the same order setmapscene takes), then
-- wScriptVar = that map's scene byte. A map with no scene_var row comes
-- back with de = 0 and the command answers $ff, NOT 0: an `ifequal 0`
-- after it must not match a map that has no scene at all, which is the
-- whole reason the sentinel exists. checkscene above has the same case
-- for the current map; this is its cross-map twin.
local args = cmd.args
local group = cmd.group or (args and args[1])
local mapNum = cmd.map or (args and args[2])
local scene = self.getMapSceneFn and self.getMapSceneFn(group, mapNum)
self.scriptVar = scene or 0xff
elseif op == "turnobject" then
-- engine/events/std_scripts.asm: turnobject LAST_TALKED resolves to the NPC last talked to
local facing = Movement.dir(cmd.facing or 0)
local object = cmd.object or 0
if object == LAST_TALKED then object = self.lastTalked end
if self.turnObjectFn then
self.turnObjectFn(object, facing)
end
elseif op == "applymovement" or op == "applymovementlasttalked" then
local object = cmd.object or 0
if op == "applymovementlasttalked" then
object = self.lastTalked or 1
end
local movKey = cmd.movement
local bytes = movKey and self.movements and self.movements[movKey]
if bytes and self.applyMovementFn then
coroutine.yield({ kind = "move", object = object, bytes = bytes })
end
elseif op == "yesorno" then
local yes = coroutine.yield({ kind = "yesorno" })
self.scriptVar = yes and 1 or 0
elseif op == "disappear" then
-- Script_disappear has a `cp LAST_TALKED` the port was missing: the
-- constant is -2, so the extracted byte is 254 and `disappear LAST_TALKED`
-- was trying to hide object 253. Script_appear has no such check, which
-- is why the branch below has none either.
local object = cmd.object or arg1(cmd)
if object == LAST_TALKED then object = self.lastTalked end
if self.disappearFn then self.disappearFn(object) end
elseif op == "appear" then
-- Script_appear: UnmaskCopyMapObjectStruct, then
-- ApplyEventActionAppearDisappear with b = 0 (CLEAR_FLAG) over the
-- object's MAPOBJECT_EVENT_FLAG. The mirror of `disappear` above, and
-- like it the object list changes NOW rather than at the next map load:
-- the cart respawns the struct inside the same command, so this must not
-- be routed through the deferred onFlagsChanged rebuild.
--
-- ApplyEventActionAppearDisappear returns without touching anything when
-- the flag word is -1 ($ffff, which the extractor writes as 65535 and
-- Events:objectVisible already reads as "always appear"), so an object
-- with no flag is made visible by the struct copy alone.
local object = cmd.object or arg1(cmd)
if self.appearFn then self.appearFn(object) end
elseif op == "moveobject" then
-- Script_moveobject: `add 4` on x and on y, then CopyDECoordsToMapObject
-- (engine/overworld/player_object.asm) writes them into
-- MAPOBJECT_X_COORD / MAPOBJECT_Y_COORD. That +4 is the same border
-- offset PlayerSpawn_ConvertCoords applies to wXCoord/wYCoord, so the
-- script's own bytes are plain map cells: the coordinate space the
-- object_event macro and the extracted obj.x / obj.y already use.
--
-- It teleports an object that is normally still hidden; every use in
-- pokegold is a `moveobject` immediately followed by an `appear`
-- (VictoryRoad's rival, Clair in DragonsDenB1F, the Fast Ship crew).
local args = cmd.args or {}
local object = cmd.object or args[1] or 0
local x = cmd.x or args[2] or 0
local y = cmd.y or args[3] or 0
if self.moveObjectFn then self.moveObjectFn(object, x, y) end
elseif op == "variablesprite" then
-- Script_variablesprite: wVariableSprites[byte] = sprite. The macro
-- emits `\1 - SPRITE_VARS`, so the first byte is already a 0-based slot
-- into that table ($f0 SPRITE_CONSOLE .. $fc SPRITE_JANINE_IMPERSONATOR
-- in constants/sprite_constants.asm) and the second is a plain
-- OverworldSprites index, the numbering constants.spriteOrder uses.
--
-- An object whose sprite IS one of those ids is extracted with a NUMBER
-- in `sprite` rather than a name (Route 36's Sudowoodo carries 244, i.e.
-- $f4 SPRITE_WEIRD_TREE), so World:pooledNpc finds no sheet for it and
-- the object never spawns. Filling the slot is what puts the disguised
-- tree, the Copycat, the Olivine rival and the four Fuchsia Gym Janines
-- on the map at all.
local args = cmd.args or {}
local slot = cmd.slot or args[1] or 0
local sprite = cmd.sprite or args[2] or 0
self.variableSprites[slot] = sprite
if self.variableSpriteFn then self.variableSpriteFn(slot, sprite) end
elseif op == "loademote" then
-- Script_loademote: EMOTE_FROM_MEM ($ff, -1 in
-- constants/script_constants.asm) means "the emote already in
-- wScriptVar", anything else is the literal id; LoadEmote then pushes
-- that bubble's tiles into VRAM. ShowEmoteScript is the only caller that
-- uses the FROM_MEM form, and Script_showemote has written its own first
-- byte to wScriptVar by the time it runs.
--
-- This port picks the sheet at draw time (World:showEmote indexes
-- emoteOrder), so the command only has to remember WHICH bubble, for a
-- later `show_emote` movement byte ($54) that carries no id of its own.
local emote = cmd.emote or arg1(cmd) or 0
if emote == EMOTE_FROM_MEM then emote = self.scriptVar or 0 end
self.loadedEmote = emote
if self.loadEmoteFn then self.loadEmoteFn(emote) end
elseif op == "pokepic" then
-- Script_pokepic opens a 7x7 window and leaves it standing: the pic is on
-- screen until a `closepokepic` (or a `reanchormap`) takes the window
-- down. The flag is what tells the `waitbutton` above that there is no
-- text box under it to have paid for the press already.
local species = cmd.species or arg1(cmd)
self.picOpen = true
if self.showPicFn then
self.showPicFn(species)
end
elseif op == "getmonname" then
local species = cmd.species or arg1(cmd)
if self.getMonNameFn then
self:setStringBuffer(self.getMonNameFn(species))
end
elseif op == "getitemname" then
local item = cmd.item or arg1(cmd) or 0
if self.getItemNameFn then
self:setStringBuffer(self.getItemNameFn(item))
end
elseif op == "getstring" then
-- The extractor already read the `@`-terminated name the pointer aims
-- at (Script_getstring CopyName1 -> wStringBuffer2), so the following
-- writetext's TX_RAM has something to print. Without this, Mom's
-- "#GEAR" line renders its {STRBUF} as nothing.
self:setStringBuffer(cmd.string)
elseif op == "gettrainername" then
if self.getTrainerNameFn then
self:setStringBuffer(self.getTrainerNameFn(cmd.group, cmd.trainer))
end
elseif op == "getcurlandmarkname" then
-- Script_getcurlandmarkname: GetWorldMapLocation on wMapGroup/wMapNumber,
-- then GetLandmarkName into a string buffer. The map is implicit, so the
-- one operand byte is only the buffer id and this port has one buffer.
-- landmarks.lua already carries the names, the line break included (the
-- town map draws them two rows deep).
local name = self.getLandmarkNameFn and self.getLandmarkNameFn()
if name then self:setStringBuffer(name) end
elseif op == "getnum" then
-- Script_getnum: PrintNum of wScriptVar (PRINTNUM_LEFTALIGN | 1 byte,
-- 3 chars) into wStringBuffer1, then GetStringBuffer copies that into
-- whichever string buffer the operand names. Left-aligned is why there
-- are no padding spaces to reproduce: tostring() is the whole of it.
-- Without this the Bug Contest judge's "You have N minutes left" prints
-- an empty {STRBUF}.
self:setStringBuffer(tostring(self.scriptVar or 0))
elseif op == "repeattext" then
-- Script_repeattext re-prints the text jumptext / jumptextfaceplayer
-- stashed in wScriptTextBank / wScriptTextAddr, and ONLY when both
-- operand bytes are -1. Read the guard carefully: the HIGH byte lands in
-- a and is compared first (`cp -1 / jr nz, .done`), then the low byte,
-- and any other pointer falls out of the command without printing
-- anything at all. It never prints the pointer it was given.
-- JumpTextScript's `repeattext -1, -1` is the only user in the ROM.
local args = cmd.args
if args and args[1] == 0xff and args[2] == 0xff and self.lastTextKey then
self:showText(self.lastTextKey)
end
elseif op == "givepoke" then
local species = cmd.species or arg1(cmd)
local level = cmd.level or (cmd.args and cmd.args[2]) or 5
local item = cmd.item or (cmd.args and cmd.args[3]) or 0
if self.givePokeFn then
self.givePokeFn(species, level, item)
end
elseif op == "checkpoke" then
-- Script_checkpoke: IsInArray over wPartySpecies. Party only, so a boxed
-- mon does not count, which is the point of the checks that gate on
-- carrying a particular species.
local species = cmd.species or arg1(cmd) or 0
local has = self.hasPokeFn and self.hasPokeFn(species)
self.scriptVar = has and 1 or 0
elseif op == "giveegg" then
-- Script_giveegg's own comment: 0 when there is no room in the party,
-- 2 when the egg went in. Not 1, so an `iftrue` after it is testing "the
-- party had room" and an `ifequal 2` is the same test spelled out.
local args = cmd.args
local species = cmd.species or (args and args[1]) or 0
local level = cmd.level or (args and args[2]) or 5
local given = self.giveEggFn and self.giveEggFn(species, level)
self.scriptVar = given and 2 or 0
elseif op == "givepokemail" then
-- `givepokemail pointer` reads an item byte plus a MAIL_MSG_LENGTH
-- message from behind a pointer in the script's own bank and hands both
-- to GivePokeMail, which hangs the mail on the LAST party member.
-- The extractor resolves that pointer into `cmd.mail = { item, message }`
-- (RomExtractorGen2's givepokemail arm); the raw word is the fallback for
-- a cache built before it, and an unresolved letter is one the model
-- refuses rather than one it invents. GivePokeMail writes no wScriptVar.
if self.givePokeMailFn then
self.givePokeMailFn(cmd.mail or wordArg(cmd))
end
elseif op == "checkpokemail" then
-- CheckPokeMail (engine/pokemon/mail.asm) opens the party list and only
-- then answers, so this BLOCKS: the coroutine parks on the yield and the
-- world's own handler resumes it with one of POKEMAIL_WRONG_MAIL 0 /
-- CORRECT 1 / REFUSED 2 / NO_MAIL 3 / LAST_MON 4.
--
-- With no handler at all, REFUSED is the honest answer: it is the value
-- SelectMonFromParty's carry produces when the player backs out, so the
-- script takes its own cancel path instead of being told a delivery
-- happened.
local expected = cmd.mail or wordArg(cmd)
if self.checkPokeMailFn then
local answer = coroutine.yield({ kind = "pokemail", mail = expected })
self.scriptVar = tonumber(answer) or POKEMAIL_REFUSED
else
self.scriptVar = POKEMAIL_REFUSED
end
elseif op == "giveitem" or op == "verbosegiveitem" then
local item = cmd.item or arg1(cmd) or 0
local qty = cmd.quantity or (cmd.args and cmd.args[2]) or 1
-- Script_giveitem's own `ld [wCurItem], a` (scripting.asm:1612). It is
-- what the standalone `specialsound` inside GiveItemScript reads back:
-- CheckItemPocket runs on wCurItem, not on anything the opcode carries.
self.curItem = item
local ok = true
if self.giveItemFn then
ok = self.giveItemFn(item, qty) ~= false
end
self.scriptVar = ok and 1 or 0
if op == "verbosegiveitem" then
local name = self.getItemNameFn and self.getItemNameFn(item) or "?"
self:setStringBuffer(name)
-- GiveItemScript (engine/overworld/scripting.asm:441-449), command for
-- command: `writetext .ReceivedItemText / iffalse .Full / waitsfx /
-- specialsound / waitbutton / itemnotify`. Both messages print into the
-- ONE MapTextbox the caller's `opentext` opened; it comes down at the
-- caller's `closetext` and at no point in between.
--
-- The `waitsfx` sits ABOVE `specialsound` -- it drains whatever sfx was
-- already sounding so the item jingle starts clean -- and the port had
-- it BELOW, parking the script on the jingle's full length. The port's
-- box waits for its own button and pops itself, so that park happened
-- with NOTHING on the stack: the text box visibly tore down and rebuilt
-- around a second of silence, and Game2's play clock (which only ticks
-- while the overworld is the top state) came off pause for the gap.
-- With the wait back on the cart's side of the sound, the second box is
-- pushed inside the same frame the first one pops -- no frame ever
-- renders the bare overworld, which is the closest this port's
-- box-per-message shape gets to the cart's single MapTextbox.
self:showRaw(Strings("{PLAYER} received\n%s.", name))
if ok then
-- GiveItemScript's `waitsfx` is NOT ported as a park, and that is the
-- fix rather than an omission. On the cart it drains whatever channel
-- the script before it left sounding, and it runs while the received
-- line is still on screen -- the box has not been touched yet, because
-- the button press is one command further down at `waitbutton`. This
-- port's box takes that press itself and pops on it, so by the time
-- the VM gets here the ONLY thing still sounding is the box's own
-- Press_AB blip, and parking on it left the bare overworld drawing for
-- the length of the blip -- exactly the seam the cart never opens.
-- The received box's typing and its press are the drain point here.
if self.specialSoundFn then
self.specialSoundFn(item)
elseif self.playSoundFn then
self.playSoundFn(1) -- SFX_ITEM
end
-- _PutItemInPocketText's second blank is wStringBuffer3, which
-- GetPocketName fills from ItemPocketNames: KEY ITEMs, BALLs and TMs
-- name their own pocket, not the ITEM one (data/text/common_2.asm
-- :1351, data/items/pocket_names.asm:10-13).
self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.",
name, self:pocketName(item)))
else
self:showRaw(Strings("The %s\nis full…", self:pocketName(item)))
end
end
elseif op == "itemnotify" then
-- Script_itemnotify is GetPocketName + CurItemName, both of which read
-- wCurItem (engine/overworld/scripting.asm:460). It touches no string
-- buffer, so the shared stand-in for wStringBuffer1..5 must not be read
-- here: it is stale by design and a plain `giveitem` / `itemnotify` pair
-- would print the last name any script happened to leave in it. Nor is
-- the buffer written back: CurItemName fills wStringBuffer1 while the
-- port's single buffer mostly stands for wStringBuffer2, so mirroring
-- the clobber would corrupt an unrelated {STRBUF} page.
local name = self:curItemName()
if name ~= "" then
self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.",
name, self:pocketName(self.curItem)))
end
elseif op == "pocketisfull" then
-- Script_pocketisfull reads wCurItem exactly as Script_itemnotify does
-- (engine/overworld/scripting.asm:468).
self:showRaw(Strings("The %s\nis full…", self:pocketName(self.curItem)))
-- ---- bag, money and coins (engine/events/money.asm) --------------------
elseif op == "checkitem" then
-- Script_checkitem clears wScriptVar FIRST and only CheckItem's carry
-- turns it TRUE, so a bag the world cannot answer for reads "no item"
-- rather than leaving whatever the command before it left behind.
local item = cmd.item or arg1(cmd) or 0
local has = self.hasItemFn and self.hasItemFn(item)
self.scriptVar = has and 1 or 0
elseif op == "takeitem" then
-- `takeitem item, quantity`; the one-argument macro form fills the
-- quantity in as 1 at assembly time, so the ROM always carries both
-- bytes. Script_takeitem puts -1 in wCurItemQuantity so TossItem removes
-- without asking, and wScriptVar is TRUE only when the pack really held
-- that many.
local args = cmd.args
local item = cmd.item or (args and args[1]) or 0
local qty = cmd.quantity or (args and args[2]) or 1
local took = self.takeItemFn and self.takeItemFn(item, qty)
self.scriptVar = took and 1 or 0
elseif op == "checkmoney" then
-- Script_checkmoney -> CompareMoney -> CompareMoneyAction: the answer is
-- HAVE_MORE 0 / HAVE_AMOUNT 1 / HAVE_LESS 2, not a boolean. The broke
-- arm is `ifequal HAVE_LESS`, and an `iffalse` after a checkmoney means
-- the player has MORE than the price, which is why guessing this one
-- would have sent every shopkeeper down the wrong branch.
local account, amount = moneyArgs(cmd)
local have = self.getMoneyFn and self.getMoneyFn(account) or 0
self.scriptVar = compareFunds(have, amount)
elseif op == "givemoney" or op == "takemoney" then
-- GiveMoney is AddMoney then a CompareMoney against MaxMoney that writes
-- MAX_MONEY back over any overflow; TakeMoney's SubtractMoney leaves the
-- account at 0 on a borrow rather than wrapping. Neither touches
-- wScriptVar, so nothing here may either.
local account, amount = moneyArgs(cmd)
if self.getMoneyFn and self.setMoneyFn then
local have = self.getMoneyFn(account) or 0
if op == "givemoney" then
self.setMoneyFn(account, math.min(have + amount, MAX_MONEY))
else
self.setMoneyFn(account, math.max(have - amount, 0))
end
end
elseif op == "getmoney" then
-- `getmoney string_buffer, account` emits the ACCOUNT byte FIRST
-- (macros/scripts/events.asm swaps the two arguments), which is the order
-- Script_getmoney reads them in: GetMoneyAccount, then GetStringBuffer.
-- PrintNum is PRINTNUM_LEFTALIGN, so no padding survives into the text,
-- and this port has one shared string buffer so the buffer id is read and
-- deliberately ignored.
local account = arg1(cmd) or 0
local have = self.getMoneyFn and self.getMoneyFn(account) or 0
self:setStringBuffer(tostring(have))
elseif op == "checkcoins" then
-- Script_checkcoins -> CheckCoins -> the same CompareMoneyAction ladder,
-- so the answers are the HAVE_* three again. The Goldenrod coin vendor
-- leads with `checkcoins MAX_COINS - 50` / `ifequal HAVE_MORE`, i.e. 0 is
-- the "your case is nearly full" arm.
local have = self.getCoinsFn and self.getCoinsFn() or 0
self.scriptVar = compareFunds(have, wordArg(cmd))
elseif op == "givecoins" or op == "takecoins" then
-- GiveCoins caps at MAX_COINS the way GiveMoney caps at MAX_MONEY, and
-- TakeCoins floors at 0 on a borrow. Neither writes wScriptVar.
local amount = wordArg(cmd)
if self.getCoinsFn and self.setCoinsFn then
local have = self.getCoinsFn() or 0
if op == "givecoins" then
self.setCoinsFn(math.min(have + amount, MAX_COINS))
else
self.setCoinsFn(math.max(have - amount, 0))
end
end
elseif op == "getcoins" then
-- Script_getcoins: wCoins through PrintNum into a string buffer.
local have = self.getCoinsFn and self.getCoinsFn() or 0
self:setStringBuffer(tostring(have))
elseif op == "pokemart" then
-- `pokemart dialog_id, mart_id` (macros/scripts/events.asm): one
-- MARTTYPE_* byte then a WORD mart id, which the extractor leaves in
-- `args` as dialog, lo, hi. Script_pokemart farcalls OpenMartDialog,
-- which does not return until the shop is closed, so this parks the VM
-- on its resume the same way `startbattle` does.
local args = cmd.args
local martType = cmd.martType or cmd.dialog or (args and args[1]) or 0
local martId = cmd.mart or cmd.martId
if not martId and args then
martId = (args[2] or 0) + (args[3] or 0) * 0x100
end
coroutine.yield({ kind = "mart", martType = martType,
martId = martId or 0 })
elseif op == "addcellnum" then
local phone = cmd.phone or arg1(cmd) or 0
if self.addCellFn then self.addCellFn(phone) end
elseif op == "delcellnum" then
local phone = cmd.phone or arg1(cmd) or 0
if self.delCellFn then self.delCellFn(phone) end
elseif op == "checkcellnum" then
local phone = cmd.phone or arg1(cmd) or 0
local has = self.hasCellFn and self.hasCellFn(phone)
self.scriptVar = has and 1 or 0
elseif op == "cry" then
if self.cryFn then self.cryFn(cmd.id) end
elseif op == "playsound" then
if self.playSoundFn then self.playSoundFn(cmd.id) end
elseif op == "playmusic" then
if self.playMusicFn then self.playMusicFn(cmd.id) end
elseif op == "playmapmusic" then
-- Script_playmapmusic: PlayMapMusic (home/audio.asm), the song off the
-- map's own header. It compares against wMapMusic first and does nothing
-- when that song is already playing, which Music.play's own dedupe
-- reproduces. Paired with `playmusic` at the end of a cutscene to hand
-- the town its theme back.
if self.playMapMusicFn then self.playMapMusicFn() end
elseif op == "musicfadeout" then
-- Script_musicfadeout: a WORD music id into wMusicFadeID, then a fade
-- byte masked with ~(1 << MUSIC_FADE_IN_F). Clearing bit 7 is what makes
-- it a fade OUT; the low bits are the frames between volume steps
-- (FadeToMapMusic uses 8, every script use in pokegold passes 16), and
-- the queued song starts once the ramp bottoms out. That is the same
-- `control` byte Music.fadeOut already takes.
local args = cmd.args or {}
local music = cmd.id or wordArg(cmd)
local fade = (cmd.fade or args[3] or 0) % 128 -- clear MUSIC_FADE_IN_F
if self.fadeOutMusicFn then self.fadeOutMusicFn(music, fade) end
elseif op == "dontrestartmapmusic" then
-- Script_dontrestartmapmusic: wDontPlayMapMusicOnReload = TRUE. It is a
-- ONE SHOT, and it does not mean "keep playing": TryRestartMapMusic
-- (home/audio.asm) sees the flag, plays MUSIC_NONE instead of the map
-- theme, zeroes wMapMusic and clears the flag again. So the next map
-- reload comes back SILENT, which is how a scripted song or a deliberate
-- silence survives the reload that follows it.
self.dontRestartMapMusic = true
if self.dontRestartMapMusicFn then self.dontRestartMapMusicFn() end
elseif op == "warpsound" then
-- Script_warpsound: GetWarpSFX (engine/overworld/tile_events.asm) then
-- PlaySFX. The choice comes off wPlayerTileCollision at play time:
-- COLL_DOOR ($71) gives SFX_ENTER_DOOR, COLL_WARP_PANEL ($7c) gives
-- SFX_WARP_TO, anything else gives SFX_EXIT_BUILDING. The tile under the
-- player decides, and the World owns that tile.
if self.warpSoundFn then self.warpSoundFn() end
elseif op == "waitsfx" then
coroutine.yield({ kind = "waitsfx" })
elseif op == "specialsound" then
-- Script_specialsound (scripting.asm:476) is `farcall CheckItemPocket`
-- over wCurItem, so the TM/HM jingle or SFX_ITEM is picked from the item
-- the last giveitem parked there -- the opcode itself carries nothing.
if self.specialSoundFn then
self.specialSoundFn(self.curItem)
elseif self.playSoundFn then
self.playSoundFn(1) -- SFX_ITEM
end
coroutine.yield({ kind = "waitsfx" })
elseif op == "readvar" then
local id = cmd.var or arg1(cmd) or 0
if self.readVarFn then
self.scriptVar = self.readVarFn(id) or 0
else
self.scriptVar = 0
end
elseif op == "writevar" then
-- Script_writevar: GetVarAction resolves the VAR_* id to an address, then
-- [de] = wScriptVar. The exact mirror of readvar, and the extractor
-- gives both the same cmd.var field. Only a handful of the rows in
-- engine/overworld/variables.asm .VarActionTable are plain addresses
-- (VAR_BATTLETYPE, wPlayerState); the RETVAR_EXECUTE rows resolve to code
-- and writing them is meaningless, which is the World's call, not this
-- file's.
if self.writeVarFn then
self.writeVarFn(cmd.var or arg1(cmd) or 0, (self.scriptVar or 0) % 256)
end
elseif op == "loadvar" then
-- Script_loadvar: GetVarAction on the var id, then [de] = a LITERAL byte
-- (args = {var, value}). writevar takes wScriptVar, loadvar takes the
-- next script byte, and that is the only difference between them. Note
-- the extractor's readvar/writevar branch matches only those two names,
-- so loadvar falls through to the generic `args` arm and does NOT get a
-- cmd.var of its own.
--
-- This is the command that arms the special battles:
-- `loadvar VAR_BATTLETYPE, BATTLETYPE_FORCEITEM` in front of Lugia, Ho-Oh
-- and the Red Gyarados, BATTLETYPE_FORCESHINY at the Lake of Rage,
-- BATTLETYPE_TRAP in the Rocket base, BATTLETYPE_CANLOSE for the
-- Cherrygrove rival. Skipping it silently is why every one of those
-- currently fights as a plain wild encounter you cannot lose to.
local args = cmd.args
local varId = cmd.var or (args and args[1]) or 0
local value = (args and args[2]) or 0
if self.writeVarFn then self.writeVarFn(varId, value % 256) end
elseif op == "readmem" then
-- Script_readmem: wScriptVar = the WRAM byte at args = {lo, hi}. Real
-- scripts use it as a counter the port has nowhere else to keep: the
-- Goldenrod underground switch room reads $d6a8
-- (wUndergroundSwitchPositions), addvals 1 or -1 and writes it straight
-- back, and Route39Barn reads wMooMooBerries the same way. So the VM
-- carries its own sparse byte store and stays self-consistent.
--
-- The hook is the seam for the addresses the ENGINE really owns: it
-- returns a number to answer for one, or nil to mean "not mine, use the
-- script's own store".
local addr = wordArg(cmd)
local value = self.readMemFn and self.readMemFn(addr)
if value == nil then value = self.mem[addr] end
self.scriptVar = (value or 0) % 256
elseif op == "writemem" or op == "loadmem" then
-- Script_writemem takes its byte from wScriptVar; Script_loadmem reads
-- the address FIRST and a literal value LAST (args = {lo, hi, value}).
-- Both share readmem's sparse store: the hook returns truthy when the
-- World has claimed that address, and anything it does not claim lands in
-- the VM's own table so a read / addval / write triple still adds up.
--
-- Nothing in the extracted cache reaches loadmem yet: the two uses in
-- pokegold are `loadmem hBGMapMode, $0` and trainer_scripts' `loadmem
-- wRunningTrainerBattleScript, -1`, both in engine code the extractor
-- never walks. Implemented anyway so it is not a silent skip the day one
-- becomes reachable.
local addr = wordArg(cmd)
local value
if op == "loadmem" then
value = ((cmd.args and cmd.args[3]) or 0) % 256
else
value = (self.scriptVar or 0) % 256
end
local handled = self.writeMemFn and self.writeMemFn(addr, value)
if not handled then self.mem[addr] = value end
elseif op == "jumpstd" then
-- StdScripts entry: the extractor already resolved the id to the same
-- scripts.lua key a map pointer would produce (see extractStdScripts),
-- so a std script runs through this very interpreter. jumpstd is a tail
-- call: nothing after it runs.
if cmd.script then runList(self, cmd.script) end
return "end"
elseif op == "callstd" then
if cmd.script then runList(self, cmd.script) end
elseif op == "special" then
self:runSpecial(cmd.id, cmd)
elseif op == "setval" then
-- setval loads wScriptVar, which the ifequal family then tests.
self.scriptVar = cmd.value or arg1(cmd) or 0
elseif op == "addval" then
-- Script_addval: `GetScriptByte / ld hl, wScriptVar / add [hl] /
-- ld [hl], a`. The operand is ADDED to wScriptVar and the result wraps
-- at 8 bits, so `addval -1` (args = {255}) is how a script counts DOWN:
-- the Goldenrod underground switch room does readmem / addval 1 /
-- writemem to flick a switch on and readmem / addval -1 / writemem to
-- flick it back off.
self.scriptVar = ((self.scriptVar or 0) + (arg1(cmd) or 0)) % 256
elseif op == "random" then
-- Script_random: a uniform roll in 0 .. n-1, where n is the operand. The
-- cart gets there the long way (.Divide256byC finds 256 % n,
-- rejection-samples Random() down to a multiple of n, then SimpleDivide
-- takes the remainder) purely so the modulo is unbiased; math.random over
-- the same span is the same distribution. `random 0` returns early on
-- `and a / ret z` with wScriptVar still holding the 0 it just stored, so
-- a zero operand is a zero result and not an error.
local n = arg1(cmd) or 0
self.scriptVar = (n == 0) and 0 or math.random(0, n - 1)
elseif op == "checkver" then
-- Script_checkver: wScriptVar = GS_VERSION, a byte assembled into the
-- command itself (constants/misc_constants.asm: 0 Gold, 1 Silver). It is
-- a plain value, not a flag, so the `iftrue` that follows is the SILVER
-- arm and Gold falls through. WhirlIslandLugiaChamber uses exactly that
-- to give Gold a level 70 Lugia and Silver a level 40. Defaults to Gold
-- so a VM built without the hook plays the Gold branch.
local version = GS_VERSION_GOLD
if self.gsVersionFn then version = self.gsVersionFn() or version end
self.scriptVar = version
elseif op == "checktime" then
-- Script_checktime: `xor a / ld [wScriptVar], a`, CheckTime hands back
-- the bit for the current wTimeOfDay in c, the script byte is ANDed with
-- it, and wScriptVar is TRUE only when the AND is non-zero. The bits are
-- shift_consts (constants/ram_constants.asm): MORN 1, DAY 2, NITE 4,
-- DARKNESS 8, and ANYTIME is MORN|DAY|NITE = 7.
--
-- CheckTime.TimeOfDayTable lists MORN_F, DAY_F, NITE_F and then NITE_F
-- again: DARKNESS_F (3) is not in it at all, so IsInArray fails, c comes
-- back 0, and `checktime` is FALSE for every mask inside a pitch-black
-- cave. Transcribed rather than smoothed over, because that is the cart.
local mask = arg1(cmd) or 0
local time = self.getTimeOfDayFn and self.getTimeOfDayFn() or 0
local bit = 0
if time == 0 then bit = 1 -- MORN_F
elseif time == 1 then bit = 2 -- DAY_F
elseif time == 2 then bit = 4 -- NITE_F
end -- DARKNESS_F falls through at 0
-- Lua 5.1 has no band; the same shift-and-test shape Events:get uses.
local hit = bit ~= 0 and math.floor(mask / bit) % 2 == 1
self.scriptVar = hit and 1 or 0
-- ---- trainer battles (engine/events/trainer_scripts.asm) ----------------
elseif op == "loadtrainer" then
-- `loadtrainer class, member` overrides whatever the object carried, so
-- a rematch script can pick JOEY2 off the same object as JOEY1.
self.trainer = self:lookupTrainer(cmd.class or arg1(cmd),
cmd.member or (cmd.args and cmd.args[2]))
elseif op == "loadtemptrainer" then
-- wTempTrainer is the copy LoadTrainer_continue takes from the struct
-- the object points at; here that is simply the object's own record.
self.trainer = self:lookupTrainer(
self.trainerObject and self.trainerObject.class,
self.trainerObject and self.trainerObject.member)
elseif op == "startbattle" then
-- Resumes with "win" / "lose"; wRunningTrainerBattleScript is set for
-- the endifjustbattled / checkjustbattled pair that follows.
--
-- Script_startbattle ends `ld a, [wBattleResult] / and
-- ~BATTLERESULT_BITMASK / ld [wScriptVar], a`, and that byte counts up
-- from a WIN: WIN 0, LOSE 1, DRAW 2 (constants/battle_constants.asm).
-- So a win is the FALSE arm, which reads backwards until you look at the
-- scripts: BurnedTower1F's `startbattle / iftrue .next / disappear
-- FIREBREATHER_DICK` only makes the beaten trainer vanish because
-- winning does not take the iftrue, and TrainerHouseB1F's
-- `reloadmapafterbattle / iffalse .End` only stops the second battle
-- because it does. 21 extracted scripts branch straight off this byte.
local outcome = coroutine.yield({ kind = "battle", trainer = self.trainer,
wild = self.wildMon })
self.wildMon = nil
self.trainer = nil
self.justBattled = true
self.battleOutcome = outcome
self.scriptVar = BATTLE_RESULTS[outcome] or BATTLE_RESULTS.win
elseif op == "loadwildmon" then
-- Script_loadwildmon rewrites wBattleScriptFlags to the WILD shape
-- ((1 << 7), no trainer bit), so the latest load command decides what
-- `startbattle` fights. This VM lives as long as the World, so a
-- trainer left over from an earlier script (a sight trainer fought on
-- the way to the lake) must not shadow the wild mon -- that stale
-- record turned the Red Gyarados A-press into a rematch with the last
-- trainer beaten.
self.trainer = nil
self.wildMon = { species = cmd.species or arg1(cmd),
level = cmd.level or (cmd.args and cmd.args[2]) }
elseif op == "randomwildmon" then
-- Script_randomwildmon: `xor a / ld [wBattleScriptFlags], a`. Clearing
-- the flags IS the command: with neither the wild bit Script_loadwildmon
-- sets ((1 << 7)) nor the trainer bit Script_loadtemptrainer sets
-- ((1 << 7) | 1), the `startbattle` that follows rolls the map's own
-- encounter table. Sweet Scent, the Bug Contest and the rock-smash path
-- all reach a battle this way.
--
-- The roll happens here rather than inside `startbattle` because this
-- port carries the chosen mon in self.wildMon; nothing between the two
-- commands can move the player or change the map, so the outcome is the
-- same and the existing startbattle branch does not have to change.
self.trainer = nil
self.wildMon = nil
if self.rollWildFn then self.wildMon = self.rollWildFn() end
elseif op == "loadpikachudata" then
-- Script_loadpikachudata: wTempWildMonSpecies = PIKACHU,
-- wCurPartyLevel = 5. It writes the pair `loadwildmon` would but leaves
-- wBattleScriptFlags alone, so on the cart it only turns into a battle
-- when something else has already asked for a wild one. A Yellow
-- leftover with 0 uses in pokegold; here so it stops falling through.
-- 25 is PIKACHU in constants/pokemon_constants.asm, the same index
-- constants.speciesOrder and World's speciesByIndex use.
self.wildMon = { species = 25, level = 5 }
elseif op == "wildon" or op == "wildoff" then
-- Script_wildon / Script_wildoff: res / set
-- STATUSFLAGS_NO_WILD_ENCOUNTERS_F, [wStatusFlags] (bit 5,
-- constants/ram_constants.asm), the gate that keeps grass quiet during an
-- escorted walk. Unreferenced by pokegold's own bytecode (the ASM sets
-- and clears the flag directly around the Bug Contest and the roaming-mon
-- scenes), but the flag is real and World:tryWildEncounter needs the same
-- switch either way.
self.wildEncounters = (op == "wildon")
if self.setWildEncountersFn then
self.setWildEncountersFn(self.wildEncounters)
end
elseif op == "swarm" then
-- Script_swarm: two bytes (a `map_id`: group, then map) handed to
-- StoreSwarmMapIndices (engine/events/specials.asm), which writes
-- wSwarmMapGroup / wSwarmMapNumber and then FALLS THROUGH into
-- SetSwarmFlag -> DAILYFLAGS1_SWARM. Both halves matter: CheckSwarmFlag
-- is what makes the swarm expire, so a port that only stores the map
-- leaves the Dunsparce call permanently live.
local args = cmd.args or {}
local group = cmd.group or args[1]
local mapNum = cmd.map or args[2]
if self.setSwarmFn then self.setSwarmFn(group, mapNum) end
elseif op == "reloadmapafterbattle" or op == "reloadmap"
or op == "refreshmap" then
-- Losing ENDS the script. Script_reloadmapafterbattle reads wBattleResult
-- and, on LOSE, does `ScriptJump Script_BattleWhiteout` -- it never comes
-- back to the command after it.
--
-- The port used to fall straight through, and every trainer script in the
-- game is written `startbattle / reloadmapafterbattle / setevent
-- EVENT_BEAT_<whoever>`, so a LOSS ran the win branch: Whitney handed out
-- EVENT_BEAT_WHITNEY to a party that had just been wiped, the Elite Four
-- could be cleared one room at a time by fainting in each, and the route
-- bot reached the Hall of Fame with two Pokemon and four badges. It also
-- quietly desynced the flags from the badges, since the badge itself is
-- given further down the same script after a scene the loser never runs.
--
-- World's own loss handling has already done the whiteout half (heal,
-- halve the money, warp to the spawn point), which is what
-- Script_BattleWhiteout does; all that was missing is that the script
-- stops here.
--
-- The hook's argument is "this reload runs a map SETUP script".
-- MapSetupScript_ReloadMap ends on `mapsetup ForceMapMusic`
-- (data/maps/setup_scripts.asm:136), so `reloadmap` /
-- `reloadmapafterbattle` are the ops that consume
-- wDontPlayMapMusicOnReload; Script_refreshmap runs no setup script at
-- all (engine/overworld/scripting.asm:2044), just
-- LoadOverworldTilemapAndAttrmapPals / ApplyTilemap / UpdateSprites.
if op == "reloadmapafterbattle" and self.battleOutcome == "lose" then
self.aborted = true
self.battleOutcome = nil
if self.reloadMapFn then self.reloadMapFn(true) end
return "end"
end
if self.reloadMapFn then self.reloadMapFn(op ~= "refreshmap") end
elseif op == "catchtutorial" then
-- `catchtutorial battle_type` runs the DUDE's catch demo
-- (engine/events/catch_tutorial.asm): the player's name is parked in
-- wMomsName and swapped for DUDE, the DUDE's own pack is loaded, an
-- auto-input stream is armed, and only then is StartBattle farcall'd.
-- The wild mon is the one the `loadwildmon RATTATA, 5` in front of the
-- command left in wTempWildMonSpecies, so it rides along here the same
-- way `startbattle` takes it.
--
-- The stream armed around StartBattle is `NO_INPUT, $ff`: it exists
-- purely to take the controller away for the length of the demo, and the
-- DUDE's actual presses come from the re-arms in PromptButton, the
-- battle menu and TutorialPack (src/core/gen2/CatchTutorial.lua).
--
-- The order below is the ASM's exactly: StartAutoInput, the battle,
-- StopAutoInput, and then the `jp Script_reloadmap` the command ends on.
-- It is NOT a terminator: the script really does continue after the
-- reload, and it leaves wScriptVar alone.
local wild = self.wildMon
self.wildMon = nil
if self.autoInputStreamFn then
self.autoInputStreamFn("CATCH_TUTORIAL")
end
if self.catchTutorialFn then
coroutine.yield({ kind = "catchtutorial",
battleType = cmd.battleType or arg1(cmd), wild = wild })
end
if self.stopAutoInputFn then self.stopAutoInputFn() end
-- `jp Script_reloadmap`, so the setup script (and its ForceMapMusic row)
-- really does run here.
if self.reloadMapFn then self.reloadMapFn(true) end
elseif op == "winlosstext" then
-- Overrides the struct's win/loss text for this battle only.
self.winTextOverride = cmd.winText
self.lossTextOverride = cmd.lossText
elseif op == "trainertext" then
local which = cmd.index or arg1(cmd) or 0
local obj = self.trainerObject or {}
local key
if which == 1 then
key = self.winTextOverride or obj.winText
elseif which == 2 then
key = self.lossTextOverride or obj.lossText
else
key = obj.seenText
end
self:showText(key)
elseif op == "trainerflagaction" then
-- EventFlagAction over the struct's beat flag; CHECK writes wScriptVar.
local action = cmd.action or arg1(cmd) or 0
local flag = self.trainerObject and self.trainerObject.event
if not flag then
self.scriptVar = 0
elseif action == 2 then -- CHECK_FLAG
self.scriptVar = self.events:get(flag) and 1 or 0
else
self.events:set(flag, action == 1) -- SET_FLAG / RESET_FLAG
if self.onFlagsChanged then self.onFlagsChanged() end
end
elseif op == "scripttalkafter" then
-- Tail call into the struct's after-battle script.
local after = self.trainerObject and self.trainerObject.scriptKey
if after then runList(self, after) end
return "end"
elseif op == "endifjustbattled" then
if self.justBattled then return "end" end
elseif op == "checkjustbattled" then
self.scriptVar = self.justBattled and 1 or 0
elseif op == "setlasttalked" then
self.lastTalked = cmd.object or arg1(cmd)
elseif op == "encountermusic" then
if self.encounterMusicFn then
self.encounterMusicFn(self.trainerObject and self.trainerObject.class)
end
elseif op == "showemote" then
-- `showemote emote, object, length` -- the ! bubble over a trainer.
local emote = cmd.emote or arg1(cmd) or 0
local object = cmd.object or (cmd.args and cmd.args[2]) or 0
local frames = cmd.frames or (cmd.args and cmd.args[3]) or 0
if self.showEmoteFn then
self.showEmoteFn(emote, object, frames)
end
-- ShowEmoteScript holds on `pause 0`, which is Script_pause reading back
-- the wScriptDelay Script_showemote wrote (scripting.asm:981, 986-991),
-- so the bubble stays up for two frames per operand byte.
self:pauseFrames(frames)
elseif op == "trainerapproach" then
-- SeenByTrainerScript's callasm TrainerWalkToPlayer + the applymovement
-- that follows it, as one step: the World owns the path.
if self.trainerApproachFn then
coroutine.yield({ kind = "approach" })
end
elseif op == "faceobject" or op == "writeobjectxy" then
-- faceobject PLAYER, LAST_TALKED squares the player up to the trainer.
if op == "faceobject" and self.faceObjectFn then
self.faceObjectFn(cmd.a or (cmd.args and cmd.args[1]),
cmd.b or (cmd.args and cmd.args[2]))
end
elseif op == "follow" or op == "follownotexact" then
-- `follow leader, follower` (macros/scripts/events.asm emits the LEADER
-- first, and Script_follow hands that byte to SetLeaderIfVisible). The
-- follower's movement type becomes SPRITEMOVEDATA_FOLLOWING: it walks
-- into whatever cell the leader has just left, one step behind, for as
-- long as the pairing lasts.
--
-- Ignoring this used to be harmless-looking and was not: the New Bark
-- Town teacher's `follow NEWBARKTOWN_TEACHER, PLAYER` is what drags the
-- player back off the coord event's tile. Without it she walked home
-- alone, the player was still standing on (1,8), and the scene fired
-- again the moment it ended -- so she was back at her spawn starting the
-- same speech over, forever.
if self.followFn then
self.followFn(cmd.a or (cmd.args and cmd.args[1]),
cmd.b or (cmd.args and cmd.args[2]))
end
elseif op == "stopfollow" then
if self.stopFollowFn then self.stopFollowFn() end
-- ---- map blocks (home/map.asm GetBlockLocation) -------------------------
elseif op == "changeblock" then
-- Script_changeblock: `add 4` on both bytes, then GetBlockLocation, whose
-- `srl` halves each of them again, so the script's x and y are CELL
-- coordinates and the block it rewrites is (x / 2, y / 2). Checked
-- against MahoganyMart1F, whose `changeblock 6, 2, $1e` is block (3, 1)
-- and whose TEAM_ROCKET_BASE_B1F warp_event sits on cell (7, 3), inside
-- exactly that block; and against BrunosRoom, whose `changeblock 4, 2,
-- $16 ; open door` is block (2, 1) with its warp_events on cells (4, 2)
-- and (5, 2).
--
-- This is Bruno's door slamming shut, the Ruins of Alph floor giving way,
-- the Mahogany staircase and the Goldenrod underground doors. The hook
-- must drop whatever the renderer has baked for this map.
local args = cmd.args or {}
local x = cmd.x or args[1] or 0
local y = cmd.y or args[2] or 0
local block = cmd.block or args[3] or 0
if self.changeBlockFn then
self.changeBlockFn(math.floor(x / 2), math.floor(y / 2), block)
end
elseif op == "changemapblocks" then
-- Script_changemapblocks: a `dba` (bank, then pointer) into
-- wMapBlocksBank / wMapBlocksPointer, then ChangeMap + BufferScreen. It
-- repaints the WHOLE map from a second copy of its blockdata rather than
-- poking one block the way changeblock does.
--
-- The three bytes are read in the order GetScriptByte reads them: bank
-- first (`dba` is `dbw bank, address`), then the pointer low byte and
-- high byte. It stays a RAW ROM pointer here -- the importer only walks
-- script pointers, so nothing under data/generated/ is keyed by one --
-- and World:changeMapBlocks is what places it, against the blockdata
-- bank/address every map's attributes already carry. A pointer no map
-- covers is a no-op there rather than a guess.
--
-- wScriptVar is untouched, as in the asm.
local args = cmd.args or {}
local bank = cmd.bank or args[1]
local pointer = cmd.address
or ((args[2] or 0) + (args[3] or 0) * 0x100)
if self.changeMapBlocksFn then
self.changeMapBlocksFn(bank, pointer)
end
elseif op == "earthquake" then
-- Script_earthquake copies EarthquakeMovement (step_shake 16 /
-- step_sleep 16 / step_end) into wEarthquakeMovementDataBuffer,
-- overwrites buffer+1 (the step_shake parameter) with the script byte,
-- overwrites buffer+3 (the step_sleep parameter) with `and %00111111` of
-- the same byte, then ScriptCalls `applymovement PLAYER, buffer`.
--
-- So ONE byte carries two numbers. The full byte is the displacement
-- ShakeScreen hands the SPRITEMOVEDATA_SCREENSHAKE object; byte & $3f is
-- how many frames the movement then sleeps for, and the sleep is what
-- holds the script. `earthquake 80` is a displacement of 80 held for 16
-- frames, not 80 frames of anything. StepFunction_Sleep decrements
-- OBJECT_STEP_DURATION once per frame, so those are the 60 Hz frames
-- waitFrames already counts.
local param = cmd.param or arg1(cmd) or 0
local frames = param % 64
if self.earthquakeFn then self.earthquakeFn(param, frames) end
self:waitFrames(frames)
-- ---- warps (home/map.asm) ----------------------------------------------
elseif op == "warp" or op == "warpfacing" then
-- Script_warpfacing FALLS THROUGH into Script_warp: it is `warp` with a
-- facing bolted on the front, not a separate jumptable case, so BOTH
-- halves run. Its byte is `maskbits NUM_DIRECTIONS` (& 3, the
-- DOWN/UP/LEFT/RIGHT order Movement.dir already speaks) and goes into
-- wPlayerSpriteSetupFlags with PLAYERSPRITESETUP_CUSTOM_FACING, so the
-- player lands facing it rather than facing wherever arrival would have
-- turned them.
--
-- Then a `map_id` (group, map) and x and y as plain map cells. Distinct
-- from the warp_events World:takeWarp already handles: those name a
-- destination WARP and take their facing from where that warp sits on the
-- destination map, this one names a raw cell.
--
-- It does NOT end the script. Script_warp's StopScript only clears
-- SCRIPT_RUNNING for the frame, exactly as Script_reloadmap's does, and
-- the script resumes once the new map is up: std_scripts.asm's
-- BugContestResultsWarpScript is `warp ROUTE_36_NATIONAL_PARK_GATE, 0, 4`
-- followed by an `applymovement PLAYER` that walks the player in. Every
-- other use in pokegold is followed by `end` anyway.
--
-- Group 0 is the routine's own error arm: it eats the remaining three
-- bytes and enters through MAPSETUP_BADWARP, which is EnterMapSpawnPoint
-- on the map you are already standing on rather than a trip anywhere.
local args = cmd.args or {}
local base = (op == "warpfacing") and 1 or 0
local facing
if op == "warpfacing" then
facing = Movement.dir(cmd.facing or args[1] or 0)
end
local group = cmd.group or args[base + 1] or 0
local mapNum = cmd.map or args[base + 2]
local x = cmd.x or args[base + 3]
local y = cmd.y or args[base + 4]
if group == 0 then
-- MAPSETUP_BADWARP, which is a full load of the map already underfoot:
-- HandleNewMap and LoadMapObjects are in its setup script and are NOT
-- in MapSetupScript_ReloadMap, so this is a different hook from the
-- `reloadmap` one above. PlayersHousePCScript is the caller that
-- cares -- the bedroom's decorations are rebuilt by those callbacks.
local reload = self.badWarpFn or self.reloadMapFn
if reload then reload() end
elseif self.warpToFn then
self.warpToFn(group, mapNum, x, y, facing)
end
elseif op == "warpcheck" then
-- Script_warpcheck: WarpCheck (home/map.asm) -> GetDestinationWarpNumber
-- + CopyWarpData, and on a hit `farcall EnableEvents`. It does NOT warp
-- by itself: 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.
--
-- That is what drops the player through the hole a `changeblock` has just
-- opened under them in RuinsOfAlphOmanyteChamber, and what puts them into
-- the Pokecenter 2F link rooms after the receptionist has walked them up.
-- The hook arms it; it must not warp mid-script.
if self.warpCheckFn then self.warpCheckFn() end
elseif op == "warpmod" then
-- Script_warpmod: a warp id, then a `map_id`, into wBackupWarpNumber,
-- wBackupMapGroup, wBackupMapNumber. That triple is where the game
-- believes you came IN from: Elevator's .FindCurrentFloor
-- (engine/events/elevator.asm) reads the backup map to work out which
-- floor you are standing on, and the escape-rope / dig return reads it to
-- put you back outside.
--
-- Unreferenced by every script in pokegold; the rows the importer reports
-- live in mis-walked regions of bank $45. Ported anyway so the state
-- exists the moment anything writes it.
local args = cmd.args or {}
local warpId = cmd.warp or args[1]
local group = cmd.group or args[2]
local mapNum = cmd.map or args[3]
if self.setWarpModFn then self.setWarpModFn(warpId, group, mapNum) end
elseif op == "blackoutmod" then
-- Script_blackoutmod: a `map_id` into wLastSpawnMapGroup /
-- wLastSpawnMapNumber, which is where a WHITEOUT puts the player rather
-- than the last Pokecenter (engine/events/whiteout.asm reads the same
-- pair; home/map.asm writes it on a normal Pokecenter entry).
--
-- The S.S. Aqua and Mr. Pokemon's house set it so that losing at sea or
-- out past Cherrygrove does not respawn you somewhere you cannot leave.
-- Distinct from the SPAWN_* id World:warpToSpawn uses today: this names a
-- group/map pair directly, so the hook has to override that lookup.
local args = cmd.args or {}
local group = cmd.group or args[1]
local mapNum = cmd.map or args[2]
if self.setBlackoutMapFn then self.setBlackoutMapFn(group, mapNum) end
elseif op == "newloadmap" then
-- Script_newloadmap: hMapEntryMethod = the byte, then LoadMapStatus
-- MAPSTATUS_ENTER and StopScript. It RE-ENTERS THE CURRENT MAP through
-- one of the MapSetupScripts (constants/map_setup_constants.asm,
-- const_def $f1: $f1 WARP, $f3 RELOADMAP, $f4 TELEPORT, $f5 DOOR,
-- $f6 FALL, $f8 LINKRETURN, $f9 TRAIN...), which is how the magnet train
-- and a link return come back onto their own map with the right fade and
-- sound. Like `warp` it does not end the script; every real use is
-- followed by `end` regardless.
local method = cmd.method or arg1(cmd) or 0
if self.newLoadMapFn then self.newLoadMapFn(method) end
-- ---- windows and menus (home/menu.asm) ---------------------------------
elseif op == "loadmenu" then
-- `loadmenu menu_header` -> LoadMenuHeader. The extractor follows the
-- pointer now, so cmd.menu is the whole MenuHeader -- flags, the four
-- border coords, the data flags and the item strings behind them. A
-- cache built before that leaves only the raw word, and the menu hook
-- answers 0 for a header it cannot draw. Stashed rather than acted on:
-- LoadMenuHeader only copies it to wMenuHeader, and the verticalmenu /
-- _2dmenu that follows is what opens it.
self.menuHeader = cmd.menu or { address = wordArg(cmd) }
elseif op == "verticalmenu" or op == "_2dmenu" then
-- Script_verticalmenu answers with wMenuCursorY, Script__2dmenu with
-- wMenuCursorPosition, and both `xor a` on the carry the menu returns for
-- B. Those cursors are 1-BASED, so the ifequal ladder after the command
-- starts at 1 and 0 is the cancel arm: the Goldenrod coin vendor is
-- loadmenu / verticalmenu / closewindow / ifequal 1 / ifequal 2 / sjump,
-- and the Day-Care grid is loadmenu / _2dmenu / closewindow /
-- ifequal 1..5. Blocks the way yesorno does: yield the request, resume
-- with the chosen index.
local choice = coroutine.yield({ kind = "menu",
style = (op == "_2dmenu") and "2d" or "vertical",
header = self.menuHeader })
self.scriptVar = tonumber(choice) or 0
elseif op == "closewindow" then
-- Script_closewindow: CloseWindow + UpdateSprites, the teardown for the
-- window loadmenu / verticalmenu / _2dmenu put up. The port's menu hook
-- owns its own screen lifetime, so there is nothing left to tear down;
-- kept as its own branch so it stops falling through the unknown-op path
-- and so the teardown has an obvious home when a real window lands.
-- ---- field events ------------------------------------------------------
elseif op == "fruittree" then
-- `fruittree tree_id` sets wCurFruitTree and JUMPS to FruitTreeScript, so
-- nothing after it in the caller runs. Opcodes.TERMINATORS does not list
-- it, so the extractor kept disassembling the bytes that followed: every
-- one of the 42 extracted fruittree scripts is this one command plus
-- garbage, which is exactly why this branch has to return.
--
-- FruitTreeScript itself is transcribed here rather than looked up. It
-- is an engine script: nothing in the ROM's bytecode points at it, so
-- neither it nor its text reaches data/generated, the same reason
-- GiveItemScript is inlined in the verbosegiveitem branch above. Text
-- bodies from data/text/common_1.asm.
local tree = cmd.tree or arg1(cmd) or 0
-- callasm GetCurTreeFruit: FruitTreeItems[tree - 1], FRUITTREE_* being
-- 1-based (constants/script_constants.asm `const FRUITTREE_ROUTE_29 ; 01`
-- and GetCurTreeFruit's own `dec a`). The hook undoes the offset.
local item = self.fruitTreeItemFn and self.fruitTreeItemFn(tree) or 0
local name = (item ~= 0 and self.getItemNameFn
and self.getItemNameFn(item)) or "BERRY"
-- readmem wCurFruit / getitemname STRING_BUFFER_3, USE_SCRIPT_VAR
self.scriptVar = item
self:setStringBuffer(name)
self:showRaw(Strings("It's a fruit-\nbearing tree."))
-- callasm TryResetFruitTrees / callasm CheckFruitTree / iffalse .fruit.
-- The reset runs BEFORE the check and gated on ENGINE_ALL_FRUIT_TREES,
-- so the first tree examined after the daily rollover refills the other
-- twenty-nine as well as its own.
if self.fruitTreeResetFn then self.fruitTreeResetFn() end
-- CheckFruitTree is a CHECK_FLAG over wFruitTreeFlags, and the per-tree
-- flag means "already picked" (ResetFruitTrees clears the lot once a
-- day), so a CLEAR flag is the arm with fruit on it.
local picked = self.fruitTreePickedFn and self.fruitTreePickedFn(tree)
if picked then
self:showRaw(Strings("There's nothing\nhere…"))
return "end"
end
self:showRaw(Strings("Hey! It's\n%s!", name))
-- readmem wCurFruit / giveitem ITEM_FROM_MEM / iffalse .packisfull
local ok = true
if self.giveItemFn then ok = self.giveItemFn(item, 1) ~= false end
self.scriptVar = ok and 1 or 0
if not ok then
self:showRaw(Strings("But the PACK is\nfull…"))
return "end"
end
self:showRaw(Strings("Obtained\n%s!", name))
-- callasm PickedFruitTree: the flag is set AFTER the fruit is banked, so
-- a full pack leaves the tree pickable.
if self.fruitTreePickFn then self.fruitTreePickFn(tree) end
if self.specialSoundFn then
self.specialSoundFn(item)
elseif self.playSoundFn then
self.playSoundFn(SFX_ITEM)
end
-- FruitTreeScript's tail is `specialsound / itemnotify` with NOTHING
-- between them (engine/events/fruit_trees.asm:23-24), and Script_specialsound
-- is a bare PlaySFX -- it does not wait either (scripting.asm:476-483).
-- The port used to park here on a `waitsfx`, which is the same seam
-- GiveItemScript's did: this port's box takes its own button and pops on
-- it, so the park ran with an EMPTY state stack and the bare overworld
-- drew for the length of the jingle (163 frames measured) between the two
-- pages of what the cart prints into ONE MapTextbox -- with Game2's play
-- clock, which only pauses while a state is on the stack, running for
-- every one of them. The obtained box's own press is the drain point.
-- itemnotify. Berries are all ITEM pocket, so nothing here moves; the
-- noun still comes from ItemPocketNames rather than from a third copy of
-- the literal (data/items/pocket_names.asm:10-13).
self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.",
name, self:pocketName(item)))
return "end"
elseif op == "describedecoration" then
-- `describedecoration byte` picks one of five DECODESC_* arms
-- (engine/overworld/decorations.asm) and JUMPS to the script each hands
-- back, so like fruittree nothing after it runs and the bytes the
-- extractor read past it are garbage.
--
-- Each arm is asm that chooses by what is INSTALLED in the player's
-- room, and the extractor emits the scripts rather than the arms: the
-- poster table plus the `end` it falls to when the wall is bare, the one
-- script the two ornaments and the console share, and the giant
-- ornament's. Which one runs is decided by the wDeco* slot the arm
-- reads, and `decorationSlot` is that read (src/core/gen2/Decorations.lua
-- owns the slots themselves).
--
-- DecorationDesc_Poster IsInArray over DecorationDesc_
-- PosterPointers on wDecoPoster, falling
-- to the bare `end` when the wall is bare
-- ..._OrnamentOrConsole one script for all three, with the
-- decoration's NAME in wStringBuffer3 --
-- "It's an adorable <name>!"
-- ..._GiantOrnament one script, no name
local kind = cmd.decoration or arg1(cmd) or 0
local descName = cmd.decorationName or ""
if self.describeDecorationFn then self.describeDecorationFn(kind) end
local arm = (self.eventTables.decorations or {})[descName]
local placed, placedName
if self.decorationSlotFn then
placed, placedName = self.decorationSlotFn(descName)
end
if descName == "DECODESC_POSTER" and arm and arm.posters then
for _, row in ipairs(arm.posters) do
if row.decoration == placed then
arm = row
break
end
end
elseif placedName then
self:setStringBuffer(placedName)
end
if arm and arm.script and self.scripts[arm.script] then
runList(self, arm.script)
return "end"
end
return "end"
elseif op == "trade" then
-- `trade trade_id` -> NPCTrade (engine/events/npc_trade.asm): a whole
-- blocking conversation (intro text, YesNoBox, a party pick, the gender
-- and species checks, the trade animation) driven off
-- data/events/npc_trades.asm, which is not extracted. NPCTrade writes no
-- wScriptVar, so with no hook the script simply carries on the way it
-- does when the player backs out.
if self.npcTradeFn then
coroutine.yield({ kind = "trade", trade = cmd.trade or arg1(cmd) or 0 })
end
elseif op == "elevator" then
-- Script_elevator: wScriptVar = 0 up front, farcall Elevator, and only a
-- NON-carry return raises it to TRUE. Elevator (engine/events/elevator.asm)
-- carries on three paths (the current floor is not in the list, the
-- player pressed B, or the player picked the floor they are already on)
-- and it performs the ride itself, so wScriptVar means only "did we
-- actually move". GoldenrodDeptStoreElevatorScript's `iffalse .Done`
-- right after is the branch that skips the SFX, the earthquake and the
-- B1F crate reshuffle.
--
-- The extractor follows the operand into the map's own floor list now
-- (db count, then `elevfloor floor, warp, map` rows), so the menu has
-- something to offer. With no list and no hook the answer stays 0, the
-- player-backed-out case: answering 1 would play out a ride that never
-- happened.
self.scriptVar = 0
if cmd.floors and #cmd.floors > 0 and self.elevatorFn then
-- Elevator_GoToFloor writes wBackupWarpNumber / wBackupMapGroup /
-- wBackupMapNumber and RIDES; the hook owns both halves, and answers
-- the floor row it went to, or nil for a cancel and for "you picked
-- the floor you are already on" (`cp [hl] / jr z, .quit`).
local rode = coroutine.yield({ kind = "elevator", floors = cmd.floors })
self.scriptVar = rode and 1 or 0
end
-- ---- phone (engine/phone/phone.asm) ------------------------------------
elseif op == "askforphonenumber" then
-- Script_askforphonenumber: YesNoBox FIRST, then AddPhoneNumber. The
-- answer is PHONE_CONTACT_GOT 0 / PHONE_CONTACTS_FULL 1 /
-- PHONE_CONTACT_REFUSED 2, so SUCCESS IS ZERO here: an `iftrue` after
-- this command means the number did NOT go in.
local contact = cmd.phone or arg1(cmd) or 0
local yes = coroutine.yield({ kind = "yesorno" })
if not yes then
self.scriptVar = PHONE_CONTACT_REFUSED
else
-- AddPhoneNumber returns carry, and so PHONE_CONTACTS_FULL, both when
-- the list is full and when the number is already in it: _CheckCellNum
-- runs before Phone_FindOpenSlot and answers with the same carry.
local added = self.addPhoneNumberFn and self.addPhoneNumberFn(contact)
self.scriptVar = added and PHONE_CONTACT_GOT or PHONE_CONTACTS_FULL
end
elseif op == "phonecall" then
-- `phonecall caller_name` -> PhoneCall: two rings, then the caller's name
-- in the telephone box. docs/bugs_and_glitches.md calls this command out
-- as one that may crash on retail (it reaches BrokenPlaceFarString, which
-- is not in bank 0), and the only occurrence in the cache sits in a run of
-- garbage past a fruittree, so nothing real depends on it. Wired to the
-- phone hook anyway, because the ring is what a script asking for it
-- wants.
if self.phoneCallFn then
coroutine.yield({ kind = "phonecall",
caller = cmd.caller or wordArg(cmd) })
end
elseif op == "hangup" then
-- HangUp: PhoneClickText with SFX_HANG_UP under it, then the four <……>
-- boops that close the call box (data/text/common_3.asm). The sound is
-- started BEFORE the line here because "Click!" ends in `done`: PrintText
-- returns without waiting on the cart, while this port's text box holds
-- until A, so playing it after would put the beep on an empty screen.
if self.playSoundFn then self.playSoundFn(SFX_HANG_UP) end
self:showRaw(Strings("Click!"))
if self.hangUpFn then self.hangUpFn() end
elseif op == "specialphonecall" then
-- `specialphonecall call_id` only STORES the id; the call itself fires
-- later, from CheckSpecialPhoneCall on an overworld step. No wScriptVar.
-- Script_specialphonecall writes two bytes but wSpecialPhoneCallID is a
-- single `db` in wram, so the high byte lands in padding and only the low
-- byte is ever read back.
local id = cmd.call or wordArg(cmd)
self.specialCall = id
if self.setSpecialCallFn then self.setSpecialCallFn(id) end
elseif op == "checkphonecall" then
-- Script_checkphonecall reads only the LOW byte of wSpecialPhoneCallID
-- (`ld a, [wSpecialPhoneCallID] / and a / jr z`), which is the whole byte
-- the queue actually uses. Transcribed as the low-byte test rather than
-- tidied into a whole-word one.
local id = self.getSpecialCallFn and self.getSpecialCallFn()
or self.specialCall or 0
self.scriptVar = ((id % 0x100) ~= 0) and 1 or 0
-- ---- end of game -------------------------------------------------------
elseif op == "halloffame" then
-- Script_halloffame: the game timer stops, HallOfFame runs, and then
-- ReturnFromCredits does Script_endall + MAPSTATUS_DONE. The script
-- stack is cleared and the overworld is torn down, so this returns
-- whether or not a hook took the screen.
if self.hallOfFameFn then
coroutine.yield({ kind = "halloffame" })
end
return "end"
elseif op == "credits" then
-- Script_credits: RedCredits, then the same ReturnFromCredits teardown
-- halloffame ends on.
if self.creditsFn then
coroutine.yield({ kind = "credits" })
end
return "end"
-- ---- commands with no engine behind them yet ---------------------------
elseif op == "deactivatefacing" then
-- Script_deactivatefacing: wScriptDelay = the byte (left ALONE when the
-- byte is 0, the same `and a / jr z` idiom Script_pause uses), then
-- wScriptMode = SCRIPT_WAIT and StopScript. WaitScript ticks that delay
-- down one per frame and calls UnfreezeAllObjects before reading again,
-- so what the command DOES is hold the script for N frames with the map's
-- objects released. This port never freezes them in the first place
-- (World:step keeps updatePeople running while the VM is busy), so the
-- wait is the whole of it and it goes through the same waitFrames `pause`
-- uses.
self:waitFrames(cmd.frames or arg1(cmd) or 0)
elseif op == "writeunusedbyte" then
-- Script_writeunusedbyte stores its operand in wUnusedScriptByte, and
-- nothing in the ROM ever reads it back: the label is pokegold's own name
-- for a dead write. Kept as an explicit branch so the byte is consumed
-- deliberately, and kept on the VM in case a romhack ever does read it.
self.unusedScriptByte = arg1(cmd) or 0
elseif op == "xycompare" then
-- Script_xycompare does nothing but store a pointer in wXYComparePointer.
-- The work happens much later, in SetXYCompareFlags (home/region.asm),
-- which walks that table against the player's position on every map load
-- and sets wXYCompareFlags, and which has its own famous bug (`ld a, $4`
-- where `add $4` was meant, so the Y coordinate is never compared).
--
-- No map in Gold uses the command; every occurrence the extractor found
-- is a data region mis-read as code. So the pointer is recorded and
-- nothing reads it, which is precisely what the cart does at this point.
-- wScriptVar is untouched, as in the asm.
self.xyComparePointer = wordArg(cmd)
elseif op == "autoinput" then
-- Script_autoinput hands a bank:pointer to StartAutoInput
-- (home/joypad.asm), which replays a canned button stream through the
-- joypad while the overworld keeps running: the player watches their
-- character move on its own. The operand is a `dba`, so the bytes are
-- bank, then the low and high halves of the address, in that order.
--
-- The extractor emits those three bytes and nothing behind them, and no
-- map in the ROM actually runs the command (every occurrence in
-- scripts.lua is a data region mis-read as code), so the hook resolves
-- the pointer against the four streams StartAutoInput really has call
-- sites for -- src/core/gen2/AutoInput.lua POINTERS -- and arms nothing
-- for anything else. Script_autoinput does not write wScriptVar, so
-- neither does this.
if self.autoInputFn then
self.autoInputFn(arg1(cmd) or 0, wordArg(cmd, 2))
end
elseif op == "writecmdqueue" then
-- Script_writecmdqueue copies a five-byte entry (CMDQUEUE_ENTRY_SIZE) out
-- of the script's own bank into the first free wCmdQueue slot;
-- HandleQueuedCommand then polls that queue every frame. Two maps use
-- it, both for CMDQUEUE_STONETABLE: the Ice Path B1F boulder puzzle and
-- the Blackthorn Gym 2F one, where the queue is what makes a pushed
-- boulder fall into the water.
--
-- The five bytes sit behind a pointer in the script's own bank and the
-- extractor emits only the pointer, so the World resolves the entry from
-- the map instead (src/world/gen2/CmdQueue.lua STONE_TABLES). Like the
-- cart's own Script_writecmdqueue this leaves wScriptVar alone.
if self.writeCmdQueueFn then
self.writeCmdQueueFn(cmd.pointer or wordArg(cmd))
end
elseif op == "delcmdqueue" then
-- Script_delcmdqueue: `xor a / ld [wScriptVar], a`, then DelCmdQueue over
-- the queue entry whose type byte matches the operand. Read the polarity
-- off the `ret c`: DelCmdQueue's .done arm clears the slot and sets carry,
-- so carry means it FOUND and deleted the entry, and that path returns
-- with wScriptVar still 0. The loop only falls through to
-- `ld a, TRUE / ld [wScriptVar], a` when it ran off the end without a
-- match. So the command answers FALSE on a successful delete and TRUE
-- when there was nothing to delete, which reads backwards until you check.
--
-- With a real queue behind it that polarity is now observable rather than
-- academic: a map that deletes its own stone table answers FALSE. With
-- no hook (or an empty queue) TRUE is still the cart's answer.
local kind = cmd.queue or arg1(cmd) or 0
local deleted = false
if self.delCmdQueueFn then deleted = self.delCmdQueueFn(kind) and true end
self.scriptVar = deleted and 0 or 1
elseif op == "unknown" or op == "truncated" then
-- Not a command: the extractor emits these when the pointer walk ran into
-- a byte that is not an opcode, or off the end of the bank, and both
-- break its disassembly loop so they are always the last row in a list.
-- Recorded separately from the unimplemented-opcode set below, because
-- what they report is a mis-walked ROM region rather than a missing
-- branch. Ending the list is the only safe reading: the cart would be
-- executing data here.
self.badBytes[cmd.code or op] = (self.badBytes[cmd.code or op] or 0) + 1
return "end"
elseif op == MOD_COMMAND then
-- A mod's verb, dispatched through the shared `commands` registry. Last
-- arm before the unknown-opcode ledger on purpose: no cart row can carry
-- this op, so a stock script has already matched a branch above and never
-- pays even this comparison.
return self:runModCommand(cmd)
else
-- No branch for this opcode. Falling through QUIETLY is the worst thing
-- this interpreter can do: a script that runs `checkitem` and then
-- `iftrue` reads a stale wScriptVar and takes the WRONG arm, which looks
-- like a content bug rather than a missing command. So keep running (a
-- hard error would make the game unplayable over one unported command)
-- but record it and say so once per opcode, and let the suite assert the
-- set is empty for a script built only of implemented commands.
self:noteUnknownOp(op)
end
end
-- `key` is a scripts.lua key, or a command list itself: the two trainer
-- scripts (engine/events/trainer_scripts.asm) are reached through a player
-- event rather than a map pointer, so nothing extracts them and the World
-- hands them over inline.
-- Assigns the forward declaration above, not a new local: runCmd calls back
-- into this for every tail-call opcode.
function runList(self, key)
local list = type(key) == "table" and key or self.scripts[key]
if not list then return end
local i = 1
while list[i] do
-- A whiteout replaces the running script rather than returning to it, so
-- the abort has to unwind every nested scall as well as this list. See
-- `reloadmapafterbattle`.
if self.aborted then return end
local cmd = list[i]
local op = cmd.op
-- A row a MOD wrote in the Gen 1 shape, { "mymod:shake", 4, 2 }, normalised
-- to the extension op so everything downstream -- the script.command hook's
-- `name`, runCmd's dispatch -- sees one row kind. The extractor stamps `op`
-- on every row it emits (src/import/RomExtractorGen2.lua:3096, plus its
-- "unknown" / "truncated" pair), so a row without one is never the cart's
-- and the two shapes cannot be confused. Vm:runModCommand has the contract.
if op == nil and type(cmd[1]) == "string" then op = MOD_COMMAND end
-- One-command lookahead, for `writetext`'s missing terminator. A text that
-- ends in `done` (home/text.asm:484) has no PromptButton, one that ends in
-- `prompt` (:470) does, and the extractor throws the terminator away -- so
-- the box cannot tell the two apart on its own. What FOLLOWS the writetext
-- can: a `yesorno` on the next row is InitYesNoTextBoxParameters going up
-- over the box that is still holding the question, which the cart never
-- closed. Vm:showText reads this to keep that box standing.
self.nextOp = list[i + 1] and list[i + 1].op or nil
local jump
if Runtime.wantsHook("script.command") then
-- The SAME hook name and the same (ctx, name, args) argument list the
-- Gen 1 runner passes (src/script/ScriptRunner.lua:164-169), so one mod
-- can log or wrap every command in both generations. What differs is
-- what a "command" IS: Gen 1 dispatches a hand-ported row
-- { "command", arg, ... } through the verb table, this VM dispatches one
-- decoded row of the CART's own bytecode. So `name` is the opcode name
-- out of src/script/gen2/Opcodes.lua and `args` is its raw operand byte
-- list -- which for most opcodes is empty, because the extractor decodes
-- the interesting operands into NAMED fields (cmd.text, cmd.script,
-- cmd.value, cmd.object). The whole decoded row rides along as a fourth
-- argument so a Gen 2 aware mod can read those without re-walking the
-- bytes; a Gen 1 shaped wrapper that only takes three simply ignores it.
--
-- Hooks:call pcalls every link and the vanilla, and the command under it
-- YIELDS (text, yesorno, movement, battle). That only works because the
-- engine runs on LuaJIT, whose pcall is resumable; stock Lua 5.1 would
-- raise "attempt to yield across a C-call boundary" here. Same contract
-- the Gen 1 runner already relies on (src/script/ScriptRunner.lua:164).
-- A mod's row reports the operands it actually dispatches with, which for
-- a Gen 1 shaped row is the row's own tail rather than an `args` field.
local args = (op == MOD_COMMAND and modArgs(cmd)) or cmd.args or {}
jump = Runtime.call("script.command", function(_, hname, hargs, hcmd)
-- Honour a link that rewrote the operand list on its way down, the way
-- `nextFn(ctx, name, newargs)` does on Gen 1: run a copy of the row
-- carrying the new operands rather than the row the cart wrote.
local row = hcmd or cmd
if hargs ~= nil and hargs ~= args and hargs ~= row.args then
local copy = {}
for k, v in pairs(row) do copy[k] = v end
copy.args = hargs
row = copy
end
-- `op` last, not row.op: a Gen 1 shaped mod row carries no `op` field
-- and it is the normalisation above that made it a modcommand.
return runCmd(self, row, hname or row.op or op)
end, self:scriptCtx(), op, args, cmd)
else
jump = runCmd(self, cmd, op)
end
if jump == "end" then
return
elseif type(jump) == "number" then
-- A hook-returned program counter, same as the Gen 1 runner's.
i = jump
else
-- Gen 1 also lets a jump be a LABEL name; this VM's rows are the cart's
-- own bytecode and carry no labels, so any other string is a mod asking
-- for something that cannot exist here. Say so once and fall through.
if type(jump) == "string" then self:noteBadJump(jump) end
i = i + 1
end
end
end
function Vm.new(scripts, text, events, hooks)
hooks = hooks or {}
local movements = (scripts and scripts.movements) or hooks.movements or {}
return setmetatable({
scripts = scripts or {},
movements = movements,
text = text or {},
events = events,
-- data/generated/events.lua: the side tables a command NAMES rather than
-- carries (the trades, the floor labels, the decoration scripts). Not the
-- same thing as `events` above, which is wEventFlags.
eventTables = hooks.eventTables or {},
-- The `commands` registry as merged into data.commands: verb -> handler,
-- for the mod verbs a mod-authored row can name (Vm:runModCommand). Left
-- ABSENT when the boot supplies none, so Vm.__index falls through to the
-- module-level Vm.setCommands default and a mod-free boot has neither.
commands = hooks.commands,
scriptVar = 0,
stringBuffer = "",
busy = false,
lastTalked = nil,
showTextFn = hooks.showText,
facePlayerFn = hooks.facePlayer,
onFlagsChanged = hooks.onFlagsChanged,
setSceneFn = hooks.setScene,
getSceneFn = hooks.getScene,
setMapSceneFn = hooks.setMapScene,
turnObjectFn = hooks.turnObject,
applyMovementFn = hooks.applyMovement,
yesornoFn = hooks.yesorno,
disappearFn = hooks.disappear,
showPicFn = hooks.showPic,
hidePicFn = hooks.hidePic,
-- WaitButton for the one command that needs a real press of its own,
-- `waitbutton` under an open `pokepic` window. Absent on a headless
-- build, and the opcode then keeps its old free pass rather than parking
-- on a resume nobody will call.
waitButtonFn = hooks.waitButton,
getMonNameFn = hooks.getMonName,
getItemNameFn = hooks.getItemName,
-- CheckItemPocket on an item index -> "ITEM" | "KEY_ITEM" | "BALL" |
-- "TM_HM", which is what GetPocketName indexes ItemPocketNames with. The
-- same lookup the world already makes for specialsound's TM/HM jingle.
getItemPocketFn = hooks.getItemPocket,
getTrainerNameFn = hooks.getTrainerName,
setStringBufferFn = hooks.setStringBuffer,
givePokeFn = hooks.givePoke,
giveItemFn = hooks.giveItem,
addCellFn = hooks.addCell,
delCellFn = hooks.delCell,
hasCellFn = hooks.hasCell,
cryFn = hooks.cry,
playSoundFn = hooks.playSound,
playMusicFn = hooks.playMusic,
specialSoundFn = hooks.specialSound,
waitSfxFn = hooks.waitSfx,
-- StartAutoInput, by script pointer (`autoinput`) and by stream name
-- (CatchTutorial), plus StopAutoInput. See src/core/gen2/AutoInput.lua.
autoInputFn = hooks.autoInput,
autoInputStreamFn = hooks.autoInputStream,
stopAutoInputFn = hooks.stopAutoInput,
readVarFn = hooks.readVar,
-- Optional: the World's own label for the map a run belongs to. Nothing
-- inside the interpreter needs it -- only Vm:scriptCtx, which falls back to
-- the cart's group:number pair when the World does not supply one.
mapIdFn = hooks.mapId,
-- `special` id -> SpecialsPointers label (constants.specialOrder).
specialOrder = hooks.specialOrder,
-- The world half of src/script/gen2/Specials.lua, as ONE sub-table: a
-- special is an independent routine, so giving each its own `xFn` field
-- here would have doubled this constructor for no gain.
specials = hooks.specials,
healPartyFn = hooks.healParty,
healAnimFn = hooks.healAnim,
nameRivalFn = hooks.nameRival,
warpToSpawnFn = hooks.warpToSpawn,
showMoneyFn = hooks.showMoney,
showCoinsFn = hooks.showCoins,
openPcFn = hooks.openPc,
-- OpenMartDialog; nil means the shop is skipped rather than hanging.
openMartFn = hooks.openMart,
-- Trainer battles: the object's `trainer` struct is pushed in by the
-- World before the script runs (LoadTrainer_continue's wTempTrainer).
lookupTrainerFn = hooks.lookupTrainer,
startBattleFn = hooks.startBattle,
-- CatchTutorial's own StartBattle, which is a different entry point: no
-- party mon is sent out and the DUDE plays it (engine/events/
-- catch_tutorial.asm).
catchTutorialFn = hooks.catchTutorial,
reloadMapFn = hooks.reloadMap,
badWarpFn = hooks.badWarp,
encounterMusicFn = hooks.encounterMusic,
showEmoteFn = hooks.showEmote,
trainerApproachFn = hooks.trainerApproach,
faceObjectFn = hooks.faceObject,
followFn = hooks.follow,
stopFollowFn = hooks.stopFollow,
-- Scene / clock / cartridge identity.
getMapSceneFn = hooks.getMapScene,
getTimeOfDayFn = hooks.getTimeOfDay,
gsVersionFn = hooks.gsVersion,
-- ENGINE_* flags (badges, Pokegear cards, the contest timer): a different
-- namespace from the wEventFlags the setevent / clearevent pair writes.
getEngineFlagFn = hooks.getEngineFlag,
setEngineFlagFn = hooks.setEngineFlag,
-- Raw WRAM bytes and VAR_* slots.
readMemFn = hooks.readMem,
writeMemFn = hooks.writeMem,
writeVarFn = hooks.writeVar,
callAsmFn = hooks.callAsm,
-- Map objects.
appearFn = hooks.appear,
moveObjectFn = hooks.moveObject,
variableSpriteFn = hooks.variableSprite,
loadEmoteFn = hooks.loadEmote,
-- Map blocks and warps.
changeBlockFn = hooks.changeBlock,
changeMapBlocksFn = hooks.changeMapBlocks,
earthquakeFn = hooks.earthquake,
warpToFn = hooks.warpTo,
warpCheckFn = hooks.warpCheck,
warpSoundFn = hooks.warpSound,
newLoadMapFn = hooks.newLoadMap,
writeCmdQueueFn = hooks.writeCmdQueue,
delCmdQueueFn = hooks.delCmdQueue,
setWarpModFn = hooks.setWarpMod,
setBlackoutMapFn = hooks.setBlackoutMap,
-- Encounters.
setSwarmFn = hooks.setSwarm,
setWildEncountersFn = hooks.setWildEncounters,
rollWildFn = hooks.rollWild,
-- Music.
playMapMusicFn = hooks.playMapMusic,
fadeOutMusicFn = hooks.fadeOutMusic,
dontRestartMapMusicFn = hooks.dontRestartMapMusic,
-- Bag, money and coins.
hasItemFn = hooks.hasItem,
takeItemFn = hooks.takeItem,
getMoneyFn = hooks.getMoney,
setMoneyFn = hooks.setMoney,
getCoinsFn = hooks.getCoins,
setCoinsFn = hooks.setCoins,
-- Party.
hasPokeFn = hooks.hasPoke,
giveEggFn = hooks.giveEgg,
givePokeMailFn = hooks.givePokeMail,
checkPokeMailFn = hooks.checkPokeMail,
getLandmarkNameFn = hooks.getLandmarkName,
-- loadmenu stashes a header for the verticalmenu / _2dmenu that follows;
-- openMenu is the blocking half, modelled on yesorno.
openMenuFn = hooks.openMenu,
-- Field events.
fruitTreeItemFn = hooks.fruitTreeItem,
fruitTreeResetFn = hooks.fruitTreeReset,
fruitTreePickedFn = hooks.fruitTreePicked,
fruitTreePickFn = hooks.fruitTreePick,
describeDecorationFn = hooks.describeDecoration,
decorationSlotFn = hooks.decorationSlot,
npcTradeFn = hooks.npcTrade,
elevatorFn = hooks.elevator,
-- Phone.
addPhoneNumberFn = hooks.addPhoneNumber,
phoneCallFn = hooks.phoneCall,
hangUpFn = hooks.hangUp,
setSpecialCallFn = hooks.setSpecialCall,
getSpecialCallFn = hooks.getSpecialCall,
-- End of game.
hallOfFameFn = hooks.hallOfFame,
creditsFn = hooks.credits,
trainerObject = nil,
trainer = nil,
justBattled = false,
lastSpecial = nil,
-- Sparse WRAM store for readmem / writemem / loadmem: the Goldenrod switch
-- room and the MooMoo berries are counters with nowhere else to live, and
-- a read / addval / write triple has to add up even with no World hook.
mem = {},
-- ENGINE_* flags, when nothing supplies getEngineFlag / setEngineFlag.
engineFlags = {},
-- wVariableSprites: slot ($f0 SPRITE_CONSOLE .. $fc) -> sprite byte.
variableSprites = {},
-- Every opcode that reached the final else, and every extractor `unknown` /
-- `truncated` row, so a test can assert both are empty for a real script.
unknownOps = {},
badBytes = {},
-- Every map callback that tried to block, by script key. Empty is the
-- invariant: see Vm:runCallback.
blockedCallbacks = {},
menuHeader = nil,
loadedEmote = nil,
lastTextKey = nil,
unusedScriptByte = nil,
xyComparePointer = nil,
specialCall = nil,
dontRestartMapMusic = false,
wildEncounters = true,
}, Vm)
end
-- The sparse WRAM store as a plain table for the save file, and back. Same
-- contract as Events:serialize / Events:restore: address -> byte, sparse, and
-- never a dense WRAM image (the cart has 8K of it and a script touches a
-- handful of bytes). Zeroes are dropped on the way out because a missing
-- address already reads back as 0 in the readmem arm above.
--
-- The addresses the World claims through readMem / writeMem are NOT in here:
-- those belong to whatever engine state answered for them and are persisted by
-- their own owner. This is only the bytes with nowhere else to live -- the
-- Goldenrod underground switches and wMooMooBerries.
function Vm:serializeMem()
local out = {}
for addr, value in pairs(self.mem) do
if value ~= 0 then out[addr] = value end
end
return out
end
function Vm:restoreMem(bytes)
if type(bytes) ~= "table" then return self end
self.mem = {}
for addr, value in pairs(bytes) do
-- A serialized file can hand these back as strings; the readmem arm
-- indexes by number, so a string key would silently read as 0.
local index, byte = tonumber(addr), tonumber(value)
if index and byte then self.mem[index] = byte % 256 end
end
return self
end
-- The unknown-opcode ledger. Warn once per opcode name so a script in a loop
-- cannot flood the log, and keep the set so gen2_vm_test can assert it stays
-- empty across the whole extracted cache.
function Vm:noteUnknownOp(op)
if op == nil then return end
if self.unknownOps[op] then
self.unknownOps[op] = self.unknownOps[op] + 1
return
end
self.unknownOps[op] = 1
Logger.warn("gen2 script: unimplemented opcode '%s' skipped", tostring(op))
end
-- Same ledger shape as noteUnknownOp: a `script.command` wrapper that returned
-- a Gen 1 LABEL name has asked for something the cart's bytecode has no notion
-- of, and a mod that does it once does it on every row, so warn once per name.
function Vm:noteBadJump(name)
self.badJumps = self.badJumps or {}
if self.badJumps[name] then return end
self.badJumps[name] = true
Logger.warn("gen2 script: script.command returned label '%s'; " ..
"this VM has no labels, falling through", tostring(name))
end
-- ---- the mod verb table ----------------------------------------------------
--
-- THE CONTRACT. Read this before adding a caller.
--
-- Gen 1 scripts are hand-written row lists ({ "command", args... }) and a mod
-- extends the language by registering a verb into the `commands` registry, which
-- src/script/ScriptRunner.lua:150 resolves by NAME on every row. There is no
-- name to resolve here: this VM runs the CART's bytecode, where a command is an
-- opcode byte and every byte that means anything already means something
-- (src/script/gen2/Opcodes.lua). Handing mods one of the free bytes would be
-- worse than useless -- ROM data that happens to start with it would decode as a
-- mod call instead of ending the pointer walk.
--
-- So the seam is a row the CART CANNOT WRITE. Opcodes.MOD_COMMAND
-- ("modcommand") is an op name with no byte behind it, and the extractor only
-- ever stamps names out of the byte table plus its own "unknown" / "truncated"
-- pair. A stock Gold boot therefore decodes byte for byte as it did before this
-- function existed and can never reach it; what reaches it is a row a mod wrote,
-- in either of two shapes:
--
-- { op = "modcommand", verb = "mymod:shake", args = { 4, 2 } } -- native
-- { "mymod:shake", 4, 2 } -- Gen 1 row
--
-- The second is the Gen 1 row shape verbatim (runList normalises it), so a mod
-- can ship ONE row list for both games as long as every row in it is its own
-- verb. A list like that is runnable today: Vm:start and Vm:runCallback both
-- take a table of rows as well as a scripts.lua key, and `scall` / `sjump`
-- targets are keys into the same pool.
--
-- The verb resolves against the SAME registry Gen 1 uses -- same registry name
-- `commands`, same record shape, a bare function or the flagged table
-- { fn, foreground, blocking } that src/script/Commands.lua:1381-1390 unpacks --
-- reached through hooks.commands at Vm.new, or through Vm.setCommands for a boot
-- path with no way into that hooks literal. `foreground` and `blocking` are
-- unpacked and ignored: they exist for Gen 1's parallel ambient runner, and this
-- VM has one script frame, so there is no second runner for them to mean
-- anything against.
--
-- The handler is called as fn(ctx, unpack(args)) with the same per-run ctx every
-- other mod-facing site in this file hands out (Vm:scriptCtx): ctx.vm where Gen 1
-- has ctx.runner, and no ctx.game / ctx.save / ctx.overworld, because this VM
-- owns none of them. Its return value speaks runCmd's vocabulary, which is Gen
-- 1's control-command vocabulary: "end" ends the list, a number is a 1-based row
-- to continue at, nil falls through to the next row. A verb may block exactly
-- the way a command does -- ctx.vm:showText, :showRaw and :waitFrames all yield
-- the VM's coroutine and Vm:resume drives them back.
function Vm.setCommands(source)
-- Module-level default rather than per-instance: instances inherit it through
-- Vm.__index, so hooks.commands still wins for a VM that was given one and
-- everybody else sees whatever the boot installed. The intended argument is
-- the merged data.commands table, which is where the `commands` registry
-- lands for both generations. Whoever installs it owns replacing it: the
-- merged table is rebuilt per boot and per mod hot reload, so the install has
-- to happen on the same beat, and nil clears it back to a mod-free VM.
Vm.commands = source
return source
end
-- verb -> handler, unpacking the record shape Commands.resolve unpacks. The
-- source is a table (data.commands) or a function(verb) for a boot that would
-- rather resolve lazily; nil for a mod-free boot, which is the only check the
-- dispatch path below pays for.
function Vm:resolveVerb(verb)
local source = self.commands
if source == nil or type(verb) ~= "string" then return nil end
local record
if type(source) == "function" then
record = source(verb)
else
record = source[verb]
end
if type(record) == "table" then return record.fn, record end
if type(record) == "function" then return record, nil end
return nil
end
function Vm:runModCommand(cmd)
local verb = cmd.verb or cmd[1]
local fn = self:resolveVerb(verb)
if type(fn) ~= "function" then
self:noteUnknownVerb(verb)
return nil
end
-- pcall, and then keep going: the same call the unimplemented-opcode arm in
-- runCmd makes, for the same reason -- one bad row out of a third-party mod
-- must not be able to make Gold unplayable. Gen 1's runner lets the error
-- reach the coroutine because a Gen 1 script IS the mod's contribution and
-- dying with it is honest; here the mod's verb is one row inside the cart's
-- own script, and taking the map's script down with it would blame the wrong
-- author. The pcall is resumable because the engine runs on LuaJIT, the same
-- contract the script.command hook above already depends on.
local ok, jump = pcall(fn, self:scriptCtx(), unpack(modArgs(cmd)))
if ok then return jump end
self:noteFailedVerb(verb, jump)
return nil
end
-- Same one-warning-per-name ledger as noteUnknownOp, kept in its OWN table: a
-- verb nobody registered is a missing mod, not an unported opcode, and
-- gen2_vm_test asserts the opcode ledger stays empty across the whole cache.
function Vm:noteUnknownVerb(verb)
local name = tostring(verb)
self.unknownVerbs = self.unknownVerbs or {}
if self.unknownVerbs[name] then
self.unknownVerbs[name] = self.unknownVerbs[name] + 1
return
end
self.unknownVerbs[name] = 1
Logger.warn("gen2 script: no command '%s' in the commands registry; " ..
"row skipped", name)
end
-- A verb that raised. Reported to the mod manager's feed as well as the log
-- when the verb names its owner: the registries drop the owner at merge time, so
-- the "modid:verb" namespace mods write themselves (mods/examples/
-- example_lost_parcel: "example_lost_parcel:count_ask") is the only handle on it
-- this side of the loader. A verb with no prefix is logged and blamed on nobody
-- rather than on a guess.
function Vm:noteFailedVerb(verb, err)
local name = tostring(verb)
self.failedVerbs = self.failedVerbs or {}
local modId = name:match("^([^:]+):")
if modId then Runtime.reportError(modId, name .. ": " .. tostring(err)) end
if self.failedVerbs[name] then return end
self.failedVerbs[name] = tostring(err)
Logger.error("gen2 script: command '%s' failed: %s", name, tostring(err))
end
-- ---- the mod-facing script lifecycle ---------------------------------------
--
-- `script.started` / `script.ended` / `script.command` are the SAME three names
-- and the same payload keys the Gen 1 runner raises
-- (src/script/ScriptRunner.lua:119-190): both events carry { ctx = ... } and
-- `script.ended` additionally carries completed = true/false, so a mod that
-- brackets a run works unchanged on Gold.
--
-- What the names MEAN differs, and pretending otherwise would be the lie:
-- Gen 1's ctx is a bag of engine services (game, overworld, save, runner) plus
-- the row list's own extras, because a Gen 1 script is hand-ported rows this
-- engine wrote. This VM runs the CART's bytecode; it owns no Game and no save,
-- and the facts that identify a run are the cart's own -- which map it belongs
-- to, which script pointer it started at, and which map object it hangs off.
-- Those are what go in, plus `vm` as the analogue of Gen 1's `runner` and
-- `generation` so a shared mod can tell the two apart without guessing.
--
-- The ctx is built once per run and memoised: a script that runs 200 commands
-- with a `script.command` wrapper installed must not build 200 tables, and the
-- Gen 1 runner likewise hands the same table to every command in a run.
function Vm:scriptCtx()
local ctx = self.ctx
if ctx then return ctx end
local group, number
if self.readVarFn then
group, number = self.readVarFn(VAR_MAPGROUP), self.readVarFn(VAR_MAPNUMBER)
end
ctx = {
vm = self,
generation = 2,
-- The scripts.lua key the run started from: "<bank>:<addr>" out of the ROM
-- walk, or the command list itself for the two inline trainer scripts the
-- World hands over (see runList).
scriptKey = self.ctxKey,
-- "script" for a CallMapScript-style run (Vm:start), "callback" for a
-- MAPCALLBACK_* body (Vm:runCallback), which the cart runs on a nested
-- frame rather than as the player's script.
kind = self.ctxKind or "script",
-- constants/map_constants.asm names a map by group + number, and that pair
-- is all the VM can see; `mapId` is the World's own label when it supplies
-- one (hooks.mapId) and the cart's "<group>:<number>" otherwise. Do not
-- parse it -- read mapGroup / mapNumber for the numbers.
-- string.format, not Strings(): this is a machine id for a mod to key on,
-- not player-facing text a translator should ever see.
mapId = (self.mapIdFn and self.mapIdFn())
or (group and string.format("%d:%d", group, number or 0)) or nil,
mapGroup = group,
mapNumber = number,
-- hLastTalked: the 1-based map object this run hangs off, as the World
-- stamped it before calling Vm:start. nil for a run no object owns, and
-- stale from the last conversation for a sign or a callback -- which is
-- exactly what wLastTalked is on the cart, so it is reported as-is.
object = self.lastTalked,
}
self.ctx = ctx
return ctx
end
-- completed = false is an abandoned run, not a short one: a whiteout that
-- unwound the list (self.aborted), a coroutine that died on a Lua error, or a
-- map callback that yielded with nowhere to park. Same distinction the Gen 1
-- runner draws when its resume fails.
function Vm:emitScriptEnded(completed)
if Runtime.wants("script.ended") then
Runtime.emit("script.ended",
{ ctx = self:scriptCtx(), completed = completed and true or false })
end
self.ctx = nil
end
-- The `trainer` struct only stores class + member; the roster comes from
-- trainers.lua through the World, so the VM never touches that table itself.
function Vm:lookupTrainer(class, member)
if not (class and member) then return nil end
if not self.lookupTrainerFn then return { class = class, member = member } end
return self.lookupTrainerFn(class, member)
end
-- `special` handlers. The script command carries an index into
-- SpecialsPointers (data/events/special_pointers.asm), which the extractor
-- turns into a name via constants.specialOrder; keying on the name rather than
-- the number means a repointed table cannot silently call the wrong routine.
--
-- The table itself lives in src/script/gen2/Specials.lua: 112 independent
-- routines are a different kind of code from one interpreter, and growing them
-- inside this file would have buried runList. Everything there is either a
-- ported handler or a deliberate stub with its reason written down.
Vm.SPECIALS = Specials.ALL
function Vm:specialName(id)
local order = self.specialOrder
if not order or not id then return nil end
return order[id + 1]
end
function Vm:runSpecial(id, _cmd)
local name = self:specialName(id)
local handler = name and Vm.SPECIALS[name]
self.lastSpecial = name or id
if handler then handler(self) end
end
-- wStringBuffer2 in one place: the VM substitutes {STRBUF} itself for the text
-- it yields, and the hook mirrors it onto the game so the shared {STRBUF} token
-- can cover any page the VM did not build (a stale buffer is the cart's own
-- behaviour -- CopyName1 never clears it).
function Vm:setStringBuffer(value)
self.stringBuffer = value or ""
if self.setStringBufferFn then self.setStringBufferFn(self.stringBuffer) end
end
-- CurItemName (engine/overworld/scripting.asm:507). It reads wCurItem and
-- NOTHING else: the name in the "put the ... in" box comes from the item the
-- last giveitem banked, never from a string buffer. Reading self.stringBuffer
-- there instead was what made Mr. Pokemon's MYSTERY EGG hand-over print
-- whatever name an earlier script had left behind (maps/MrPokemonsHouse.asm
-- :31-35 is a plain `giveitem` / `itemnotify` pair with a getstring before it).
function Vm:curItemName()
if not (self.getItemNameFn and self.curItem) then return "" end
return self.getItemNameFn(self.curItem) or ""
end
-- GetPocketName (engine/overworld/scripting.asm:488). A VM built without the
-- hook (drivers, tests) keeps printing ITEM POCKET, which is the pocket the
-- overwhelming majority of the items these boxes name really live in.
function Vm:pocketName(item)
local pocket = self.getItemPocketFn and item and self.getItemPocketFn(item)
return POCKET_NAMES[pocket] or POCKET_NAMES.ITEM
end
function Vm:emitFace(doFace)
if doFace and self.facePlayerFn then self.facePlayerFn() end
end
-- True when the command after the one being run is the cart's YES/NO prompt,
-- i.e. this box must not pop before the prompt goes up over it. `promptbutton`
-- is deliberately NOT included: its own arm is a no-op here, so a box held open
-- for it would never be taken down, and the player presses A exactly once
-- either way.
function Vm:textStays()
return self.nextOp == "yesorno"
end
-- `stay` is also settable per command, for the hand-ported scripts that hold
-- the box open over something that is not `yesorno`: HiddenItems'
-- FindItemInBallScript prints the found line, plays SFX_ITEM and then holds on
-- `pause 60` before the itemnotify line goes into the SAME MapTextbox
-- (engine/events/misc_scripts.asm:13-17). A one-command lookahead cannot see
-- that -- the next op is `playsound`, not the hold -- so the transcription says
-- so itself.
--
-- `hold` is the cart `pause` those held-over commands contain, in frames. It
-- travels with the text rather than being run as a `pause` of its own because
-- the port's world does not tick while a box is on the stack (Game2:update
-- stops at the top state), so the only clock that can count it is the box's;
-- World:showText is where it lands.
function Vm:showRaw(body, stay, hold)
if not body or body == "" then body = "..." end
if self.stringBuffer and self.stringBuffer ~= "" then
body = body:gsub("{STRBUF}", self.stringBuffer)
end
if self.showTextFn then
coroutine.yield({
kind = "text",
text = body,
stay = (stay or self:textStays()) and true or false,
hold = hold,
})
end
end
function Vm:showText(textKey)
local body = textKey and self.text[textKey]
-- wScriptTextAddr: jumptext / jumptextfaceplayer park their pointer there and
-- JumpTextScript's `repeattext -1, -1` is what actually prints it.
if textKey then self.lastTextKey = textKey end
if not body or body == "" then body = "..." end
if self.stringBuffer and self.stringBuffer ~= "" then
-- Only the {STRBUF} marker reads the buffer. The buffer is STALE by
-- design (CopyName1 never clears it, so a berry picked an hour ago is
-- still sitting in wStringBuffer2), which is why a text without the
-- marker must never have the buffer spliced in on a guess: every
-- extracted `received` text is complete, marker or literal.
body = body:gsub("{STRBUF}", self.stringBuffer)
end
if self.showTextFn then
coroutine.yield({ kind = "text", text = body, stay = self:textStays() })
end
end
-- Script_pause's frame count, and the same hold `earthquake`, `showemote` and
-- `deactivatefacing` end on. There is deliberately NO world hook here: a wait
-- has to SUSPEND the script, so it yields and Vm:resume parks the count in
-- waitLeft, which Vm:update spends. A hook would have run the wait beside the
-- VM instead of inside it, and the script would have walked on through it.
function Vm:waitFrames(n)
if n and n > 0 then
coroutine.yield({ kind = "wait", frames = n })
end
end
-- `pause` and `showemote`, the two commands that go through Script_pause
-- itself (engine/overworld/scripting.asm:2110-2124, and ShowEmoteScript's
-- `pause 0` reading the wScriptDelay Script_showemote just wrote). Its inner
-- loop is `ld c, 2 / call DelayFrames` ONCE PER UNIT, so the hardware holds
-- two frames per operand byte: `pause 60` is 120 frames.
--
-- The factor lives here and not in waitFrames because the other two waiters do
-- not share the routine. `deactivatefacing` hands the byte to WaitScript,
-- which does one `dec [wScriptDelay]` per frame (:30-42), and `earthquake`
-- spends it as a step_sleep, which StepFunction_Sleep also decrements once per
-- frame -- both are already 1:1 and doubling them would be a new bug.
function Vm:pauseFrames(n)
self:waitFrames(Vm.pauseLength(n))
end
-- The same doubling, without the yield: a `pause` that a transcription folded
-- into the text row above it (rawtext's `hold`) is counted by the box, not by
-- the VM, but it is the same Script_pause operand and must not read differently.
function Vm.pauseLength(n)
return (n or 0) * 2
end
function Vm:running()
return self.busy
end
function Vm:start(scriptKey)
if self.busy or not scriptKey then return false end
if type(scriptKey) ~= "table" and not self.scripts[scriptKey] then
return false
end
self.busy = true
self.scriptVar = 0
-- wRunningTrainerBattleScript and the win/loss overrides are per-run: the
-- next script must not see the last one's battle.
self.justBattled = false
self.battleOutcome = nil
self.winTextOverride = nil
self.lossTextOverride = nil
-- The whiteout abort is per-run too: a script that ended because the player
-- was wiped must not stop the next one before it starts.
self.aborted = false
-- The mod-facing run identity, rebuilt per run (Vm:scriptCtx). Set BEFORE
-- the emit so the event carries this run's key, not the last one's.
self.ctx, self.ctxKey, self.ctxKind = nil, scriptKey, "script"
if Runtime.wants("script.started") then
Runtime.emit("script.started", { ctx = self:scriptCtx() })
end
self.co = coroutine.create(function()
runList(self, scriptKey)
end)
self:resume()
return true
end
-- ExecuteCallbackScript (home/map.asm), which is how a MAPCALLBACK_* body runs.
--
-- Not the same entry as Vm:start. RunMapCallback does NOT check wScriptRunning
-- the way CallMapScript does, and it must not: every warp a SCRIPT takes is a
-- map load with that script still parked (Script_warp calls StopScript, which
-- clears SCRIPT_RUNNING for the frame and leaves wScriptPos where it was), and
-- the four callbacks inside the setup script run before it resumes. So a
-- callback has to be runnable while this VM is busy.
--
-- What the cart saves around the nested run is wScriptMode and wScriptFlags,
-- and CallCallback pushes the parent's bank/position onto wScriptStack so
-- Script_endcallback's ExitScriptSubroutine pops straight back to it. Here the
-- parked coroutine and the request it is parked on ARE that stack frame, so
-- they are what gets saved and put back.
--
-- wScriptVar is deliberately NOT saved: the cart does not save it either, so a
-- callback's own `checkevent` really does clobber the parent script's copy.
--
-- Nothing reachable from an extracted callback yields (no text, no battle, no
-- movement -- ScriptEvents runs inside the map load, with no frame to come back
-- on), and a callback that tried to would have nowhere to park. One that does
-- is abandoned with the parent's frame put back untouched, and recorded, rather
-- than silently overwriting the request the parent is waiting on.
function Vm:runCallback(scriptKey)
if not scriptKey then return false end
if type(scriptKey) ~= "table" and not self.scripts[scriptKey] then
return false
end
local parent = {
busy = self.busy, co = self.co, pending = self.pending,
waitLeft = self.waitLeft,
waitSfx = self.waitSfx, waitSfxLeft = self.waitSfxLeft,
-- The mod-facing ctx is part of the parent's frame: the callback runs as
-- its own `script.started` / `script.ended` pair, and the parent's next
-- command must go back to reporting the parent's run.
ctx = self.ctx, ctxKey = self.ctxKey, ctxKind = self.ctxKind,
}
-- SCRIPT_RUNNING is set for the nested run (EnableScriptMode), so a hook that
-- asks "is a script up?" -- the deferred object rebuild is the one that does
-- -- answers the same yes it would inside any other script.
self.busy = true
self.co, self.pending = nil, nil
self.waitLeft, self.waitSfx, self.waitSfxLeft = nil, nil, nil
local abortedBefore = self.aborted
self.aborted = false
self.ctx, self.ctxKey, self.ctxKind = nil, scriptKey, "callback"
if Runtime.wants("script.started") then
Runtime.emit("script.started", { ctx = self:scriptCtx() })
end
local co = coroutine.create(function() runList(self, scriptKey) end)
local ok, req = coroutine.resume(co)
local finished = ok and coroutine.status(co) == "dead"
-- Closed out while this run's ctx is still current: a callback that yielded
-- with nowhere to park is abandoned, which is the completed = false case,
-- and so is one that died on a Lua error (re-raised below).
self:emitScriptEnded(finished)
self.busy, self.co, self.pending = parent.busy, parent.co, parent.pending
self.waitLeft = parent.waitLeft
self.waitSfx, self.waitSfxLeft = parent.waitSfx, parent.waitSfxLeft
self.aborted = abortedBefore
self.ctx, self.ctxKey, self.ctxKind = parent.ctx, parent.ctxKey, parent.ctxKind
if not ok then error(req) end
if not finished then
self:noteBlockedCallback(scriptKey, req)
return false
end
return true
end
-- The blocked-callback ledger, the same shape as the unknown-opcode one: warn
-- once per script key so a callback that runs on every map load cannot flood
-- the log, and keep the set so a test can assert it stays empty.
function Vm:noteBlockedCallback(scriptKey, req)
local key = tostring(scriptKey)
self.blockedCallbacks = self.blockedCallbacks or {}
if self.blockedCallbacks[key] then return end
self.blockedCallbacks[key] = (req and req.kind) or true
Logger.warn("gen2 map callback '%s' blocked on '%s'; abandoned",
key, tostring(req and req.kind or "?"))
end
-- RunSceneScript's tail (engine/overworld/events.asm:414-429): the scene body
-- has just run to its `end`, and only NOW is the script `sdefer` recorded
-- CallScript'd -- as this pass's player event, so it runs after the whole scene
-- body rather than at the sdefer command's own position. A whiteout unwound
-- the script instead of ending it, so nothing is left to defer to.
function Vm:runDeferred()
local script = self.deferred
self.deferred = nil
if not script or self.aborted then return false end
return self:start(script)
end
function Vm:resume(resumeValue)
if not self.co then return end
local ok, req = coroutine.resume(self.co, resumeValue)
if not ok then
self.busy = false
self.co = nil
self:emitScriptEnded(false)
error(req)
end
if coroutine.status(self.co) == "dead" then
self.busy = false
self.co = nil
self.pending = nil
-- A whiteout unwound the list rather than running it out, so `aborted` is
-- exactly the completed = false case. Emitted BEFORE runDeferred, which
-- starts a whole new run and would otherwise nest this run's `ended`
-- inside the next run's `started`.
self:emitScriptEnded(not self.aborted)
self:runDeferred()
return
end
self.pending = req
if req and req.kind == "text" and self.showTextFn then
self.showTextFn(req.text, function()
self:resume()
end, req.stay, req.hold)
elseif req and req.kind == "wait" then
self.waitLeft = req.frames or 0
elseif req and req.kind == "waitbutton" then
-- Only yielded when the hook exists (see the opcode), so no fallback arm:
-- an arm that resumed immediately would put the bug straight back.
self.waitButtonFn(function() self:resume() end)
elseif req and req.kind == "yesorno" and self.yesornoFn then
self.yesornoFn(function(yes)
self:resume(yes and true or false)
end)
elseif req and req.kind == "move" and self.applyMovementFn then
self.applyMovementFn(req.object, req.bytes, function()
self:resume()
end)
elseif req and req.kind == "waitsfx" then
self.waitSfx = true
self.waitSfxLeft = 180 -- safety cap (~3s) if a source never ends
elseif req and req.kind == "battle" then
if self.startBattleFn then
self.startBattleFn(req.trainer, req.wild, function(outcome)
self:resume(outcome)
end)
else
self:resume("win")
end
elseif req and req.kind == "catchtutorial" then
-- farcall StartBattle: the script is parked here until the demo battle is
-- over, because StopAutoInput and the map reload are on the other side of
-- it. Only reached when the hook exists, so there is no fallback arm.
self.catchTutorialFn(req.wild, req.battleType, function()
self:resume()
end)
elseif req and req.kind == "mart" then
if self.openMartFn then
self.openMartFn(req.martType, req.martId, function() self:resume() end)
else
self:resume()
end
elseif req and req.kind == "approach" then
self.trainerApproachFn(function() self:resume() end)
elseif req and req.kind == "menu" then
-- Script_verticalmenu / Script__2dmenu. Same shape as yesorno: the
-- coroutine is parked on the yield and the menu's own callback resumes it
-- with the 1-based index (0 or nil for B). With no hook the script takes
-- the cancel arm rather than hanging on a resume nobody will call.
if self.openMenuFn then
self.openMenuFn(req.header, req.style, function(choice)
self:resume(choice)
end)
else
self:resume(0)
end
elseif req and req.kind == "pokemail" then
-- CheckPokeMail's SelectMonFromParty half. Only reached when the hook
-- exists (the opcode answers REFUSED outright otherwise), so there is no
-- fallback arm here.
self.checkPokeMailFn(req.mail, function(answer) self:resume(answer) end)
elseif req and req.kind == "trade" then
self.npcTradeFn(req.trade, function() self:resume() end)
elseif req and req.kind == "elevator" then
self.elevatorFn(req.floors, function(rode) self:resume(rode) end)
elseif req and req.kind == "phonecall" then
self.phoneCallFn(req.caller, function() self:resume() end)
elseif req and req.kind == "halloffame" then
self.hallOfFameFn(function() self:resume() end)
elseif req and req.kind == "credits" then
self.creditsFn(function() self:resume() end)
end
end
function Vm:update()
if not self.busy then return end
if self.waitLeft and self.waitLeft > 0 then
self.waitLeft = self.waitLeft - 1
if self.waitLeft <= 0 then
self.waitLeft = nil
self:resume()
end
return
end
if self.waitSfx then
local done = true
if self.waitSfxFn then
done = self.waitSfxFn()
end
if self.waitSfxLeft then
self.waitSfxLeft = self.waitSfxLeft - 1
if self.waitSfxLeft <= 0 then done = true end
end
if done then
self.waitSfx = nil
self.waitSfxLeft = nil
self:resume()
end
end
end
return Vm