-- 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_`, 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 !" -- ..._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: ":" 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 ":" 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