import { DecorationData, emptyDecorations, type Decorations, } from "../world/decorations"; import { RomReader } from "../rom/reader"; import { EMPTY_ROUTINES, ENGINE_FLAGS, NATIVE_ROUTINES, NUM_FRUIT_TREES, EVENTS, OVERWORLD, RAM, SPECIAL_ROUTINES, SYMBOLS, } from "../rom/profile"; import { PokemonData } from "../rom/pokemon"; import { C } from "../battle/constants"; import { copyBag, emptyBag, hasItem, receiveItem, tossItem, type Bag, } from "../party/bag"; import { NICKNAME_PATTERN, PARTY_LENGTH, caughtData, generatePartyMon, type PartyMon, } from "../party/pokemon"; import { TrainerData, type TrainerHeader } from "../rom/trainers"; import { DIRECTION_ORDER, type Direction } from "../world/types"; import { OW } from "../world/constants"; import { readMovement, type MovementStep } from "./movement"; import { defaultTextContext, readString, readText, type TextContext, } from "./text"; export interface ClockSettings { /** Days added to the device weekday, chosen through SetDayOfWeek. */ weekdayOffset: number; dst: boolean; } export interface ScriptState { decorations: Decorations; events: Set; flags: Set; variables: Record; /** Scene values keyed by the map that owns the scene variable. */ scenes: Record; phone: number[]; clock: ClockSettings; party: PartyMon[]; bag: Bag; pokedex: { seen: Set; caught: Set }; /** wMoney: a 24-bit amount. */ money: number; /** wFruitTreeFlags: trees (FRUITTREE_* ids) picked since the last reset. */ fruitTrees: Set; /** Local calendar day of the last CheckDailyResetTimer reset; null until * the first check of a new game, which resets immediately. */ dailyReset: number | null; /** wLastSpawnMapGroup/wLastSpawnMapNumber: the outdoor map last entered * through a Pokémon Center door (EnterMapWarp's SetSpawn), used by * GetWhiteoutSpawn. Null before any Center has been entered. */ lastSpawnMap: string | null; /** wSpecialPhoneCallID: nonzero while a saved story call is pending. */ specialPhoneCall: number; rivalName: string; /** wPlayerState (VAR_MOVEMENT): walking, biking, skating or surfing. */ playerState: number; /** wBackupWarpNumber/wBackupMapGroup/wBackupMapNumber: where a warp * numbered -1 leads, recorded on arrival or by warpmod and elevators. */ backupWarp: { map: string; warp: number } | null; /** wStatusFlags bits without engine flag numbers: STATUSFLAGS_FLASH_F * (a dark cave lit) and STATUSFLAGS_NO_WILD_ENCOUNTERS_F (wildoff). */ flash: boolean; noWildEncounters: boolean; } export const emptyScriptState = (): ScriptState => ({ decorations: emptyDecorations(), events: new Set(), flags: new Set(), variables: {}, scenes: {}, phone: [], clock: { weekdayOffset: 0, dst: false }, party: [], bag: emptyBag(), pokedex: { seen: new Set(), caught: new Set() }, money: C.START_MONEY, fruitTrees: new Set(), dailyReset: null, lastSpawnMap: null, specialPhoneCall: 0, rivalName: "???", playerState: 0, backupWarp: null, flash: false, noWildEncounters: false, }); export const copyScriptState = (state: ScriptState): ScriptState => ({ decorations: { ...state.decorations }, events: new Set(state.events), flags: new Set(state.flags), variables: { ...state.variables }, scenes: { ...state.scenes }, phone: [...state.phone], clock: { ...state.clock }, party: structuredClone(state.party), bag: copyBag(state.bag), pokedex: { seen: new Set(state.pokedex.seen), caught: new Set(state.pokedex.caught), }, money: state.money, fruitTrees: new Set(state.fruitTrees), dailyReset: state.dailyReset, lastSpawnMap: state.lastSpawnMap, specialPhoneCall: state.specialPhoneCall, rivalName: state.rivalName, playerState: state.playerState, backupWarp: state.backupWarp && { ...state.backupWarp }, flash: state.flash, noWildEncounters: state.noWildEncounters, }); /** Object operands keep the script's numbering: 0 is the player, 2+ are map * objects. LAST_TALKED is resolved before an effect is emitted. */ export const PLAYER = 0; const LAST_TALKED = 0xfe; /** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */ const CONTACT_LIST_SIZE = 10; const MONS_PER_BOX = 20; /** One row of a mart's buy or sell list. `owned` is set only when selling. */ export interface MartEntry { item: number; name: string; price: number; owned?: number; } export type ScriptEffect = | { kind: "townmap" } /** Pages the player acknowledges. With holdTicks the last page instead * stays for a fixed time, as `writetext` followed by `pause` does. */ | { kind: "text"; pages: string[]; holdTicks?: number } | { kind: "yesno"; pages: string[] } | { kind: "weekday"; pages: string[]; names: string[] } | { kind: "turn"; object: number; direction: Direction } | { kind: "face"; object: number; target: number } | { kind: "move"; object: number; steps: MovementStep[] } | { kind: "follow"; leader: number; follower: number } | { kind: "stopfollow" } | { kind: "appear"; object: number } | { kind: "disappear"; object: number } | { kind: "moveobject"; object: number; x: number; y: number } | { kind: "emote"; object: number; emote: number; ticks: number } | { kind: "pause"; ticks: number } /** A front picture in a box (pokepic); species 0 closes it. */ | { kind: "picture"; species: number } /** The naming screen; answered with the name, or "" to keep `current`. */ | { kind: "name"; pages: string[]; current: string; max: number } /** pokemart's BUY/SELL/QUIT box; answered 0/1/2. */ | { kind: "martmenu"; prompt: string; selected: number } /** The mart's item list (buy: full stock; sell: the bag's pockets); * answered with the chosen entry's index, or a negative number to cancel. */ | { kind: "martlist"; mode: "buy" | "sell"; entries: MartEntry[]; selected: number; } /** The 1-99 quantity stepper; answered with the chosen amount, or a * number below 1 to cancel back to the item list. */ | { kind: "martquantity"; mode: "buy" | "sell"; item: number; name: string; price: number; max: number; prompt: string; selected: number; } /** startbattle: answered with wBattleResult. */ | { kind: "battle"; wild?: { species: number; level: number }; trainer?: { classId: number; id: number; winText?: number; lossText?: number; }; canLose?: boolean; /** catchtutorial: the Dude's demonstration (BATTLETYPE_TUTORIAL). */ tutorial?: boolean; } /** newloadmap and warp: the world enters this map after the script * ends, facing `facing` (warpfacing) or down (SpawnInFacingDown). */ | { kind: "warp"; map: string; x: number; y: number; facing?: Direction } /** A vertical menu (the elevator's floors); answered with the chosen * item's index, or -1 when cancelled with B. */ | { kind: "menu"; pages: string[]; items: string[]; selected: number } /** warpcheck: the player's tile events run again after the script. */ | { kind: "warpcheck" } /** changeblock: the block covering map tile (x, y) becomes `block`. */ | { kind: "changeblock"; x: number; y: number; block: number } /** changemapblocks: replace the map's block data from the ROM. */ | { kind: "mapblocks"; address: number } /** writecmdqueue with CMDQUEUE_STONETABLE: the map's stone table * (warp, object, script) that boulders falling into pits trigger. */ | { kind: "stonetable"; address: number }; /** What the world supplies about the present moment. */ export interface ScriptHost { mapId: string; lastTalked: number; /** A map's scene variable owner, or undefined when it has no scenes. */ sceneOwner(mapId: string): string | undefined; /** wTimeOfDay: morning 0, day 1, night 2. */ timeOfDay(): number; clock(): { weekday: number; hours: number; minutes: number }; deviceWeekday(): number; text(): TextContext; data: PokemonData; /** wPlayerID and wPlayerGender (1 for Kris). */ player(): { id: number; gender: 0 | 1 }; /** The Random routine: one byte. */ random(): number; /** GetWorldMapLocation for the current map. */ landmark(): number; /** The player's facing and position, for readvar. */ facing(): number; position(): { x: number; y: number }; /** OBJECT_MAP_X/Y of a visible object (script numbering), if any. */ objectPosition(id: number): { x: number; y: number } | undefined; /** wPlayerTileCollision and wEnvironment. */ playerCollision(): number; environment(): number; } export class UnsupportedScript extends Error { constructor( readonly offset: number, readonly opcode: number, detail = "", ) { super( `Script command 0x${opcode.toString(16)}${detail} at 0x${offset.toString(16)} is not ported.`, ); } } type Input = number | string | undefined; /** ITEM_FROM_MEM: take the item from wScriptVar. */ const ITEM_FROM_MEM = 0xff; /** wCallerContact's script-2 pointer (bank, address) within the contact. */ const PHONE_CONTACT_SCRIPT2_BANK = 9; /** TrainerWalkToPlayer and ComputePathToWalkToPlayer (home/movement.asm): * a one-tick sleep, then normal steps to the player's tile, dropping the * last step so the trainer stops beside the player. Returns the raw * wMovementBuffer bytes. */ export function trainerWalkBuffer( trainer: { x: number; y: number }, player: { x: number; y: number }, distance: number, ): number[] { const SLEEP_1 = 0x3e, STEP = 0x0c, END = 0x47; const [DOWN, UP, LEFT, RIGHT] = [0, 1, 2, 3]; const buffer = [SLEEP_1]; if ((distance - 1) & 0xff) { let x = (trainer.x - player.x) & 0xff, h = LEFT; if (trainer.x < player.x) { x = (player.x - trainer.x) & 0xff; h = RIGHT; } let y = (trainer.y - player.y) & 0xff, v = UP; if (trainer.y < player.y) { y = (player.y - trainer.y) & 0xff; v = DOWN; } // Swapped only when the y distance is the smaller one. const path: [number, number][] = y < x ? [ [v, y], [h, x], ] : [ [h, x], [v, y], ]; for (const [direction, count] of path) for (let i = 0; i < count; i++) buffer.push(STEP | direction); buffer.pop(); // DecrementMovementBufferCount } buffer.push(END); return buffer; } /** A native interpreter for the game's own event format, NOT GB assembly. * Execution is resumable: presentation effects are yielded and the caller * resumes with button, choice or animation completion. All state changes are * made to a private draft that the caller commits only after the script ends. * Unknown commands abort the whole interaction. */ export class ScriptRun { readonly state: ScriptState; lastTalked: number; /** Set by sdefer: a script to start after this one returns. */ deferred?: number; done = false; private strings: Record = {}; /** Native values for text_decimal, keyed by RAM address (wItemQuantityChange, * hMoneyTemp): the mart's quantity and price texts. */ private numbers: Record = {}; /** wCurItem: the item named by itemnotify and pocketisfull. */ private currentItem = 0; /** wCurFruitTree and wCurFruit, set by fruittree and GetCurTreeFruit. */ private fruitTree = 0; private fruit = 0; /** wTempTrainer, copied from a map trainer's header by LoadTrainer_continue. */ private tempTrainer?: TrainerHeader; /** wRunningTrainerBattleScript: zeroed by LoadTrainer_continue, set to -1 * after the battle so the trainer's after-script ends at once. */ private runningTrainerBattleScript = 0; /** wMovementBuffer, filled by TrainerWalkToPlayer and SurfStartStep. */ private movementBuffer?: number[]; /** wCurPartyMon as CheckPartyMove leaves it, and wStrengthSpecies. */ private curPartyMon = 0; private strengthSpecies = 0; private iterator: Generator; private instructions = 0; constructor( private reader: RomReader, start: number, state: ScriptState, private host: ScriptHost, private options: { callback?: boolean; /** The wild Pokémon chosen by the encounter check (wTempWildMonSpecies). */ wild?: { species: number; level: number }; /** wItemBallItemID/wItemBallQuantity, copied from the itemball data the * facing object's script pointer names. */ itemBall?: { item: number; quantity: number }; /** A map trainer (TalkToTrainer or _CheckTrainerBattle): its header * address and wSeenTrainerDistance. */ trainer?: { header: number; distance: number }; /** wHiddenItemData, copied from a BGEVENT_ITEM's hiddenitem data. */ hiddenItem?: { event: number; item: number }; /** wCallerContact's second script, copied by CheckSpecialPhoneCall * from the queued SpecialPhoneCallList entry. */ callerScript?: number; /** A field move's context from TrySurfOW, TryCutOW and similar: the * party member CheckPartyMove found (wCurPartyMon), GetSurfType's * wSurfingPlayerState, and the block Cut or Whirlpool replaces * (wCutWhirlpoolOverworldBlockAddr and its replacement). */ fieldMove?: { mon: number; surfState?: number; block?: { x: number; y: number; block: number }; }; /** An item used from the pack: UseItem names it in wStringBuffer1 and * CopyName1 copies it to wStringBuffer2. */ usedItem?: number; } = {}, ) { this.state = copyScriptState(state); this.lastTalked = host.lastTalked; this.curPartyMon = options.fieldMove?.mon ?? 0; // TrySurfOW and similar name the Pokémon before calling their script. if (options.fieldMove) this.partyNickname(); if (options.usedItem !== undefined) { const name = host.data.itemName(options.usedItem); this.strings[RAM.stringBuffer1] = name; this.strings[RAM.stringBuffer2] = name; } if (options.trainer) this.tempTrainer = new TrainerData(reader).header(options.trainer.header); this.iterator = this.execute(start); } /** Advance until the next effect, or undefined when the script ended. */ resume(input?: Input): ScriptEffect | undefined { if (this.done) return undefined; this.instructions = 0; const next = this.iterator.next(input); if (next.done) { this.done = true; return undefined; } return next.value; } private text(): TextContext { return { ...this.host.text(), rival: this.state.rivalName, strings: { ...this.host.text().strings, ...this.strings }, numbers: { ...this.host.text().numbers, ...this.numbers }, }; } private *execute(start: number): Generator { const r = this.reader, stack: number[] = []; let p = start, value = 0, battleResult = 0, spawn = 0; let wild = this.options.wild; let trainer: { classId: number; id: number } | undefined; // LoadTrainer_continue's copy fills wWinTextPointer/wLossTextPointer too. let winText = this.tempTrainer?.beatenText || undefined, lossText = this.tempTrainer?.lostScript || undefined; let canLose = false; const temp = (offset: number, opcode: number) => { if (!this.tempTrainer) throw new UnsupportedScript(offset, opcode, " (no map trainer)"); return this.tempTrainer; }; // One array for the whole run: specials append to and clear it in place. const pages: string[] = []; const byte = () => r.byte(p++); const word = () => { const result = r.word(p); p += 2; return result; }; const near = () => { const result = r.near(p, r.bank(p)); p += 2; return result; }; const far = () => { const result = r.far(p); p += 3; return result; }; const object = (id: number) => (id === LAST_TALKED ? this.lastTalked : id); const write = (address: number) => { const content = readText(r, address, this.text()); if (content) pages.push(...content.split("\n\n")); if (pages.length > 64) throw new Error("Too many dialogue pages."); }; const take = () => pages.splice(0, pages.length); let picture = false; // Waits for the player when text is waiting; the DOM shows whole pages. const flush = function* (): Generator { if (pages.length) yield { kind: "text", pages: take() }; }; const data = this.host.data; const buffer = (index: number) => [RAM.stringBuffer3, RAM.stringBuffer4, RAM.stringBuffer5][index] ?? RAM.stringBuffer3; const synchronous = (effect: ScriptEffect) => { if ( this.options.callback && ![ "turn", "appear", "disappear", "moveobject", "changeblock", "mapblocks", "stonetable", ].includes(effect.kind) ) throw new Error("Map callbacks cannot present scenes."); return effect; }; const sceneOwner = (mapId: string) => this.host.sceneOwner(mapId); // GetWhiteoutSpawn/IsSpawnPoint: the table index of the last recorded // spawn map, or SPAWN_HOME when unset or no longer a listed spawn point. const findSpawn = (mapId: string | null) => { if (mapId) for ( let entry = SYMBOLS.spawnPoints, index = 0; r.byte(entry) !== 0xff; entry += 4, index++ ) if (`${r.byte(entry)}:${r.byte(entry + 1)}` === mapId) return index; return 0; }; for (;;) { if (++this.instructions > 4096) throw new Error("Script instruction budget exceeded."); const offset = p, opcode = byte(); switch (opcode) { case 0x00: { const address = near(); stack.push(p); p = address; break; } case 0x01: { const address = far(); stack.push(p); p = address; break; } case 0x02: { // memcall: only Script_ReceivePhoneCall's caller script is known. const address = word(); const script = this.options.callerScript; if ( address !== RAM.callerContact + PHONE_CONTACT_SCRIPT2_BANK || script === undefined ) throw new UnsupportedScript(offset, opcode); stack.push(p); p = script; break; } case 0x03: p = near(); break; case 0x04: p = far(); break; case 0x06: case 0x07: case 0x0a: case 0x0b: { const compared = byte(), address = near(); const match = opcode === 6 ? value === compared : opcode === 7 ? value !== compared : opcode === 10 ? value > compared : value < compared; if (match) p = address; break; } case 0x08: case 0x09: { const address = near(); if (Boolean(value) === (opcode === 9)) p = address; break; } case 0x0c: case 0x0d: { const index = word(); if (index >= 52) throw new Error("Invalid standard script."); if (opcode === 0x0d) stack.push(p); p = r.far(SYMBOLS.standardScripts + index * 3); break; } case 0x0f: { const index = word(); const entry = SYMBOLS.specials + index * 3; const routine = r.address(r.byte(entry), r.word(entry + 1)); const result = yield* this.special(routine, value, pages, take); if (result === undefined) throw new UnsupportedScript(offset, opcode, ` (special ${index})`); value = result; break; } case 0x11: case 0x12: { const mapId = `${byte()}:${byte()}`; const owner = sceneOwner(mapId); if (opcode === 0x11) value = owner ? (this.state.scenes[owner] ?? 0) : 0xff; else { if (!owner) throw new Error("setmapscene targets a map without scenes."); this.state.scenes[owner] = byte(); } break; } case 0x13: { const owner = sceneOwner(this.host.mapId); value = owner ? (this.state.scenes[owner] ?? 0) : 0xff; break; } case 0x14: { const owner = sceneOwner(this.host.mapId); if (!owner) throw new Error("setscene on a map without scenes."); this.state.scenes[owner] = byte(); break; } case 0x15: value = byte(); break; case 0x16: value = (value + byte()) & 255; break; case 0x0e: { // callasm runs assembly: only empty or translated routines pass. const routine = far(); if (routine === NATIVE_ROUTINES.halveMoney) this.state.money = this.state.money >> 1; else if (routine === NATIVE_ROUTINES.getCurTreeFruit) this.fruit = r.byte(SYMBOLS.fruitTreeItems + this.fruitTree - 1); else if (routine === NATIVE_ROUTINES.tryResetFruitTrees) { // Trees regrow once per daily reset of ENGINE_ALL_FRUIT_TREES. if (!this.state.flags.has(ENGINE_FLAGS.allFruitTrees)) { this.state.fruitTrees.clear(); this.state.flags.add(ENGINE_FLAGS.allFruitTrees); } } else if (routine === NATIVE_ROUTINES.checkFruitTree) value = Number(this.state.fruitTrees.has(this.fruitTree)); else if (routine === NATIVE_ROUTINES.pickedFruitTree) this.state.fruitTrees.add(this.fruitTree); else if (routine === NATIVE_ROUTINES.getWhiteoutSpawn) spawn = findSpawn(this.state.lastSpawnMap); else if (routine === NATIVE_ROUTINES.setMemEvent) { // SetMemEvent: the hidden item's wHiddenItemEvent flag. const hidden = this.options.hiddenItem; if (!hidden) throw new UnsupportedScript(offset, opcode, " (no hidden item)"); this.state.events.add(hidden.event); } else if (routine === NATIVE_ROUTINES.trainerWalkToPlayer) { const distance = this.options.trainer?.distance, from = this.host.objectPosition(this.lastTalked); if (distance === undefined || !from || this.lastTalked === PLAYER) throw new UnsupportedScript(offset, opcode, " (no seen trainer)"); this.movementBuffer = trainerWalkBuffer( from, this.host.position(), distance, ); } else if (routine === NATIVE_ROUTINES.tryReceiveItem) { // FindItemInBallScript.TryReceiveItem: give the ball's item and // name it for FoundItemText (wStringBuffer3), matching GetItemName // + CopyName2. wScriptVar (value) reports success for iffalse. const ball = this.options.itemBall; if (!ball) throw new UnsupportedScript(offset, opcode, " (no item ball)"); const name = data.itemName(ball.item); this.strings[RAM.stringBuffer1] = name; this.strings[RAM.stringBuffer3] = name; this.currentItem = ball.item; value = Number( receiveItem(data, this.state.bag, ball.item, ball.quantity), ); } else if (routine === NATIVE_ROUTINES.hangUp) { // HangUp_Beep prints "Click!", then seven 20-frame waits for the // hang-up tones pass before closetext; no button is needed. write(SYMBOLS.phoneClickText); yield { kind: "text", pages: take(), holdTicks: 140 }; } else if ( routine === NATIVE_ROUTINES.ringTwiceStartCall || routine === NATIVE_ROUTINES.initCallReceiveDelay ) { // The ringing tones and caller name box are not presented yet. // InitCallReceiveDelay restarts the random-call timers, and // random calls are not ported. } else if (routine === OVERWORLD.GetPartyNickname) this.partyNickname(); else if (routine === OVERWORLD["AskCutScript.CheckMap"]) // CheckMapForSomethingToCut, evaluated by the world beforehand. value = Number(this.options.fieldMove?.block !== undefined); else if ( routine === OVERWORLD.CutDownTreeOrGrass || routine === OVERWORLD.DisappearWhirlpool ) { const block = this.options.fieldMove?.block; if (!block) throw new UnsupportedScript(offset, opcode, " (nothing to cut)"); yield synchronous({ kind: "changeblock", ...block }); } else if (routine === OVERWORLD.TryStrengthOW) { // 0 asks to use Strength, 1 lacks the move or badge, 2 is active. const mon = this.partyMove(OW.STRENGTH); value = mon < 0 || !this.state.flags.has(OW.ENGINE_PLAINBADGE) ? 1 : this.state.flags.has(OW.ENGINE_STRENGTH_ACTIVE) ? 2 : 0; if (mon >= 0) this.curPartyMon = mon; } else if (routine === OVERWORLD.SetStrengthFlag) { this.state.flags.add(OW.ENGINE_STRENGTH_ACTIVE); this.strengthSpecies = this.state.party[this.curPartyMon]?.species ?? 0; this.partyNickname(); } else if ( routine === OVERWORLD["Script_UsedWaterfall.CheckContinueWaterfall"] ) { const tile = this.host.playerCollision(); value = Number(!(tile === 0x33 || tile === 0x3b)); // CheckWaterfallTile } else if (routine === OVERWORLD["UsedSurfScript.stubbed_fn"]) { // StubbedTrainerRankings_Surf: the cut Trainer Rankings feature. } else if (!Object.values(EMPTY_ROUTINES).includes(routine as never)) throw new UnsupportedScript(offset, opcode); break; } case 0x19: { // readmem: the fruit a tree bears, or a hidden item's ID. const address = word(); if (address === RAM.curFruit) value = this.fruit; else if (address === RAM.hiddenItemID && this.options.hiddenItem) value = this.options.hiddenItem.item; else if ( address === RAM.surfingPlayerState && this.options.fieldMove?.surfState !== undefined ) value = this.options.fieldMove.surfState; else if (address === RAM.strengthSpecies) value = this.strengthSpecies; else throw new UnsupportedScript(offset, opcode); break; } case 0x1b: { // loadmem: only the trainer script's wRunningTrainerBattleScript. const address = word(), stored = byte(); if (address !== RAM.runningTrainerBattleScript) throw new UnsupportedScript(offset, opcode); temp(offset, opcode); this.runningTrainerBattleScript = stored; break; } case 0x9b: { // Script_fruittree jumps to FruitTreeScript for FRUITTREE_* id. const tree = byte(); if (tree < 1 || tree > NUM_FRUIT_TREES) throw new Error("Invalid fruit tree."); this.fruitTree = tree; p = SYMBOLS.fruitTreeScript; break; } case 0x1e: { // loadvar: VAR_BATTLETYPE's normal and CANLOSE modes, and // VAR_MOVEMENT (wPlayerState). const variable = byte(), setting = byte(); if (variable === 8) { this.setPlayerState(setting, offset, opcode); break; } if (variable !== 3 || (setting !== 0 && setting !== 1)) throw new UnsupportedScript( offset, opcode, " (battle type or variable)", ); canLose = setting === 1; break; } case 0x1d: { // writevar: only VAR_MOVEMENT is writable natively. const variable = byte(); if (variable !== 8) throw new UnsupportedScript( offset, opcode, ` (variable ${variable})`, ); this.setPlayerState(value, offset, opcode); break; } case 0x5b: trainer = undefined; wild = this.options.wild; break; // randomwildmon clears wBattleScriptFlags. case 0x5d: wild = { species: byte(), level: byte() }; trainer = undefined; break; case 0x5e: trainer = { classId: byte(), id: byte() }; wild = undefined; break; case 0x5c: { // loadtemptrainer: wOtherTrainerClass/ID from wTempTrainer. const header = temp(offset, opcode); trainer = { classId: header.classId, id: header.id }; wild = undefined; break; } case 0x62: { // trainertext: seen, beaten or loss text from wTempTrainer. const header = temp(offset, opcode), text = [header.seenText, header.beatenText, header.lostScript][ byte() ]; if (!text) throw new UnsupportedScript(offset, opcode, " (text)"); write(text); break; } case 0x63: { // trainerflagaction on wTempTrainerEventFlag. FlagAction leaves a // nonzero mask after setting or clearing, so wScriptVar is TRUE. const flag = temp(offset, opcode).eventFlag, action = byte(); if (action === 0) this.state.events.delete(flag); else if (action === 1) this.state.events.add(flag); else if (action !== 2) throw new UnsupportedScript(offset, opcode, " (flag action)"); value = action === 2 ? Number(this.state.events.has(flag)) : 1; break; } case 0x65: { const after = temp(offset, opcode).afterScript; if (!after) throw new UnsupportedScript(offset, opcode, " (no script)"); p = after; break; } case 0x66: // endifjustbattled: Script_end, which also returns from a call. temp(offset, opcode); if (!this.runningTrainerBattleScript) break; if (stack.length) { p = stack.pop()!; break; } yield* flush(); return; case 0x67: temp(offset, opcode); value = Number(this.runningTrainerBattleScript !== 0); break; case 0x64: { // Null pointers request the battle engine's default end text. const bank = r.bank(p), won = word(), lost = word(); winText = won ? r.address(bank, won) : undefined; lossText = lost ? r.address(bank, lost) : undefined; break; } case 0x5f: { if (!wild && !trainer) throw new UnsupportedScript(offset, opcode, " (no opponent)"); yield* flush(); const result = yield synchronous({ kind: "battle", wild, trainer: trainer && { ...trainer, winText, lossText }, canLose, }); if (typeof result !== "number") throw new Error("Invalid battle result."); battleResult = result; value = result & 0x3f; // Script_startbattle also updates wScriptVar. break; } case 0x61: { // catchtutorial: CatchTutorial runs StartBattle as the Dude, then // Script_reloadmap. The battle result is not read. if (byte() !== C.BATTLETYPE_TUTORIAL || !wild || trainer) throw new UnsupportedScript(offset, opcode, " (battle type)"); yield* flush(); yield synchronous({ kind: "battle", wild, tutorial: true }); wild = undefined; canLose = false; break; } case 0x60: // Script_reloadmapafterbattle: a loss continues with the whiteout. if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript; canLose = false; break; case 0x7b: // MAPSETUP_RELOADMAP restores graphics/audio after battle without // re-running callbacks or rebuilding objects. Native presentation // already retains the map; clear wBattleScriptFlags equivalents. wild = undefined; trainer = undefined; canLose = false; break; case 0x8a: { byte(); // newloadmap MAPSETUP_WARP const entry = SYMBOLS.spawnPoints + spawn * 4; yield synchronous({ kind: "warp", map: `${r.byte(entry)}:${r.byte(entry + 1)}`, x: r.byte(entry + 2), y: r.byte(entry + 3), }); break; } case 0x1c: value = this.readVar(byte(), offset); break; case 0x3b: // blackoutmod writes wLastSpawnMapGroup/wLastSpawnMapNumber. this.state.lastSpawnMap = `${byte()}:${byte()}`; break; case 0x37: case 0x38: // wildon/wildoff: STATUSFLAGS_NO_WILD_ENCOUNTERS_F. this.state.noWildEncounters = opcode === 0x38; break; case 0x3a: { // warpmod: wBackupWarpNumber and wBackupMapGroup/Number. const warp = byte(); this.state.backupWarp = { warp, map: `${byte()}:${byte()}` }; break; } case 0x3c: case 0xa3: { // warp and warpfacing: MAPSETUP_WARP with SPAWN_N_A. A zero map // group is the BADWARP fallback, which is not ported. const facing = opcode === 0xa3 ? DIRECTION_ORDER[byte() & 3] : undefined; const group = byte(), number = byte(), x = byte(), y = byte(); if (!group) throw new UnsupportedScript(offset, opcode, " (bad warp)"); yield synchronous({ kind: "warp", map: `${group}:${number}`, x, y, facing, }); break; } case 0x8c: { // deactivatefacing: SCRIPT_WAIT for the given number of ticks. const ticks = byte(); yield* flush(); yield synchronous({ kind: "pause", ticks: ticks || 1 }); break; } case 0x8e: yield synchronous({ kind: "warpcheck" }); break; case 0x8f: { // stopandsjump: the script resumes at the target on the next tick. const target = near(); yield* flush(); yield synchronous({ kind: "pause", ticks: 1 }); p = target; break; } case 0x95: { // elevator: Elevator (engine/events/elevator.asm). value = 0; const floors = this.elevatorFloors(near()); const origin = floors.findIndex( (floor) => floor.map === this.state.backupWarp?.map, ); if (origin < 0) break; write(OVERWORLD.AskFloorElevatorText); const now = readString( r, OVERWORLD.Elevator_CurrentFloorText, this.text(), ); const answer = yield { kind: "menu", pages: [...take(), `${now} ${floors[origin].name}`], items: floors.map((floor) => floor.name), selected: 0, }; if ( typeof answer !== "number" || !Number.isInteger(answer) || answer >= floors.length ) throw new Error("Invalid elevator floor."); if (answer < 0 || answer === origin) break; // Elevator_GoToFloor copies the floor's warp and map to the backup. this.state.backupWarp = { warp: floors[answer].warp, map: floors[answer].map, }; value = 1; break; } case 0x9c: this.state.specialPhoneCall = word(); break; case 0x9d: value = Number(this.state.specialPhoneCall !== 0); break; case 0x1f: case 0x9e: { const item = byte(), quantity = byte(); this.currentItem = item === ITEM_FROM_MEM ? value : item; value = Number( receiveItem(data, this.state.bag, this.currentItem, quantity), ); if (opcode === 0x9e) { // verbosegiveitem names the item, then calls the ROM's own // GiveItemScript for its text and pocket message. const name = data.itemName(this.currentItem); this.strings[RAM.stringBuffer1] = name; this.strings[RAM.stringBuffer4] = name; stack.push(p); p = SYMBOLS.giveItemScript; } break; } case 0x20: { const item = byte(), quantity = byte(); value = Number( tossItem( this.state.bag, item === ITEM_FROM_MEM ? value : item, quantity, ), ); break; } case 0x21: { const item = byte(); value = Number( hasItem(this.state.bag, item === ITEM_FROM_MEM ? value : item), ); break; } case 0x2c: { const species = byte(); value = Number( this.state.party.some((mon) => mon.species === species), ); break; } case 0x2d: { const species = byte(), level = byte(), item = byte(), gift = byte(); if (gift) throw new UnsupportedScript(offset, opcode, " (gift with OT)"); if (this.state.party.length >= PARTY_LENGTH) throw new UnsupportedScript( offset, opcode, " (full party; PC boxes)", ); const player = this.host.player(); // GeneratePartyMonStats draws the Attack/Defense byte first. const dvs = (this.host.random() << 8) | this.host.random(); const mon = generatePartyMon(data, { species, level, item, otId: player.id, ot: this.host.text().player, dvs, caughtData: caughtData( this.host.timeOfDay(), level, player.gender, this.host.landmark(), ), }); this.state.party.push(mon); this.state.pokedex.seen.add(species); this.state.pokedex.caught.add(species); // GiveANickname_YesNo reads the species name from wStringBuffer1. this.strings[RAM.stringBuffer1] = mon.nickname; write(SYMBOLS.caughtAskNicknameText); if (yield { kind: "yesno", pages: take() }) { // The naming screen's title: "'S" / "NICKNAME?". let second = SYMBOLS.nicknameStrings; while (r.byte(second++) !== 0x50); const suffix = readString(r, SYMBOLS.nicknameStrings, this.text()); const title = readString(r, second, this.text()); const name = yield { kind: "name", pages: [`${mon.nickname}${suffix}\n${title}`], current: mon.nickname, max: 10, }; if (typeof name !== "string") throw new Error("Invalid nickname."); if (name && !NICKNAME_PATTERN.test(name)) throw new Error("Invalid nickname."); if (name) mon.nickname = name; } value = 0; break; } case 0x40: case 0x41: { const id = byte() || value, target = buffer(byte()); this.strings[RAM.stringBuffer1] = opcode === 0x40 ? data.speciesName(id) : data.itemName(id); this.strings[target] = this.strings[RAM.stringBuffer1]; break; } case 0x45: case 0x46: { this.strings[RAM.stringBuffer1] = data.itemName(this.currentItem); this.strings[RAM.stringBuffer3] = data.pocketName( data.itemPocket(this.currentItem), ); write( opcode === 0x45 ? SYMBOLS.putItemInPocketText : SYMBOLS.pocketIsFullText, ); break; } case 0x48: byte(); // reanchormap redraws the screen; the operand is unused. break; case 0x56: { const species = byte() || value; data.baseData(species); picture = true; yield synchronous({ kind: "picture", species }); break; } case 0x57: picture = false; yield synchronous({ kind: "picture", species: 0 }); break; case 0x28: { const contact = byte(); const phone = this.state.phone; value = Number( !phone.includes(contact) && phone.length < CONTACT_LIST_SIZE, ); if (value) phone.push(contact); break; } case 0x29: { const index = this.state.phone.indexOf(byte()); value = Number(index >= 0); if (index >= 0) this.state.phone.splice(index, 1); break; } case 0x2a: value = Number(this.state.phone.includes(byte())); break; case 0x2b: value = Number(Boolean(byte() & (1 << this.host.timeOfDay()))); break; case 0x31: value = Number(this.state.events.has(word())); break; case 0x32: this.state.events.delete(word()); break; case 0x33: this.state.events.add(word()); break; case 0x34: value = Number(this.state.flags.has(word())); break; case 0x35: this.state.flags.delete(word()); break; case 0x36: this.state.flags.add(word()); break; case 0x9a: { const which = byte(), data = new DecorationData(r, this.host.data); if (which === 0) { const id = this.state.decorations.poster; let table = OVERWORLD.DecorationDesc_PosterPointers; while (r.byte(table) !== 0xff && r.byte(table) !== id) table += 3; if (r.byte(table) === 0xff) return; p = r.near(table + 1, r.bank(table)); } else if (which === 3) p = OVERWORLD["DecorationDesc_GiantOrnament.BigDollScript"]; else { const slot = ({ 1: "left", 2: "right", 4: "console" } as const)[ which as 1 | 2 | 4 ]; if (!slot) throw new UnsupportedScript(offset, opcode); this.strings[RAM.stringBuffer3] = data.name( this.state.decorations[slot], ); p = OVERWORLD[ "DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript" ]; } break; } case 0x44: { const source = near(), buffer = byte(); // getstring copies into wStringBuffer3 + n * STRING_BUFFER_LENGTH. const targets = [ RAM.stringBuffer3, RAM.stringBuffer4, RAM.stringBuffer5, ]; const target = targets[buffer] ?? targets[0]; this.strings[target] = readString(r, source, this.text(), 19); break; } case 0x47: break; // opentext: the native dialogue opens with its first page. case 0x49: yield* flush(); break; case 0x54: case 0x55: // A button wait with only a picture on screen still waits. if (!pages.length && picture) yield { kind: "text", pages: [""] }; yield* flush(); break; case 0x4b: write(far()); break; case 0x4c: write(near()); break; case 0x4e: { const answer = yield { kind: "yesno", pages: take() }; value = answer ? 1 : 0; break; } case 0x51: case 0x52: case 0x53: { const address = opcode === 0x52 ? far() : near(); if (opcode === 0x51 && this.lastTalked !== PLAYER) yield synchronous({ kind: "face", object: this.lastTalked, target: PLAYER, }); write(address); yield* flush(); return; } case 0x68: this.lastTalked = byte(); break; case 0x69: case 0x6a: { const target = opcode === 0x69 ? object(byte()) : this.lastTalked; let steps: MovementStep[]; if (r.word(p) === RAM.movementBuffer) { word(); const buffer = this.movementBuffer; if (!buffer) throw new UnsupportedScript(offset, opcode, " (movement buffer)"); steps = readMovement({ byte: (index) => buffer[index] }, 0); } else steps = readMovement(r, near()); yield* flush(); yield synchronous({ kind: "move", object: target, steps }); break; } case 0x6b: if (this.lastTalked !== PLAYER) yield synchronous({ kind: "face", object: this.lastTalked, target: PLAYER, }); break; case 0x6c: { // faceobject A, B turns A toward B (GetRelativeFacing). const subject = object(byte()), target = object(byte()); yield synchronous({ kind: "face", object: subject, target }); break; } case 0x6d: { const sprite = byte(); this.state.variables[sprite] = byte(); break; } case 0x6e: yield synchronous({ kind: "disappear", object: object(byte()) }); break; case 0x6f: yield synchronous({ kind: "appear", object: byte() }); break; case 0x70: { const leader = object(byte()), follower = object(byte()); yield synchronous({ kind: "follow", leader, follower }); break; } case 0x71: yield synchronous({ kind: "stopfollow" }); break; case 0x72: { const target = object(byte()), x = byte(), y = byte(); yield synchronous({ kind: "moveobject", object: target, x, y }); break; } case 0x73: { // writeobjectxy: the object's current tile becomes its map position. const target = object(byte()), position = this.host.objectPosition(target); if (target === PLAYER || !position) throw new UnsupportedScript(offset, opcode, " (object)"); yield synchronous({ kind: "moveobject", object: target, ...position, }); break; } case 0x75: { const emote = byte(), target = byte(), ticks = byte(); if (target !== LAST_TALKED) this.lastTalked = target; yield* flush(); yield synchronous({ kind: "emote", object: this.lastTalked, emote, ticks: ticks || 256, }); break; } case 0x76: { const target = object(byte()), direction = DIRECTION_ORDER[byte() & 3]; yield synchronous({ kind: "turn", object: target, direction }); break; } case 0x79: yield synchronous({ kind: "mapblocks", address: far() }); break; case 0x7a: { const x = byte(), y = byte(); yield synchronous({ kind: "changeblock", x, y, block: byte() }); break; } case 0x7c: break; // refreshmap redraws the screen; native draws every tick. case 0x7d: { // writecmdqueue: the cmdqueue macro's type and data pointer. Only // CMDQUEUE_STONETABLE is used by the game's maps. const entry = near(), type = r.byte(entry); if (type !== 2) throw new UnsupportedScript(offset, opcode, " (queue type)"); yield synchronous({ kind: "stonetable", address: r.near(entry + 1, r.bank(entry)), }); break; } case 0x78: { // earthquake: applymovement PLAYER with EarthquakeMovement, its // step_shake set to the operand and step_sleep to its low 6 bits. const displacement = byte(); yield* flush(); yield synchronous({ kind: "move", object: PLAYER, steps: [ { kind: "shake", displacement }, { kind: "sleep", ticks: displacement & 0x3f || 256 }, ], }); break; } // Audio commands are consumed; native sound sequencing is pending. case 0x7f: case 0x84: case 0x85: word(); break; case 0x81: word(); byte(); break; case 0x80: // encountermusic case 0x82: case 0x83: case 0x86: case 0x87: case 0x88: break; case 0x8b: { const ticks = byte() || 256; if (pages.length) { yield { kind: "text", pages: take(), holdTicks: ticks }; } else yield synchronous({ kind: "pause", ticks }); break; } case 0x8d: this.deferred = near(); break; case 0x90: yield* flush(); return; case 0x91: if (stack.length) { p = stack.pop()!; break; } yield* flush(); return; case 0x93: yield* flush(); return; case 0x94: { const type = byte(), martId = word(); if (type !== 0 /* MARTTYPE_STANDARD */) throw new UnsupportedScript(offset, opcode, " (mart type)"); yield* flush(); yield* this.pokemart(martId, pages); break; } default: throw new UnsupportedScript(offset, opcode); } if (stack.length > 16) throw new Error("Script call depth exceeded."); } } /** OpenMartDialog/MartDialog/StandardMart (engine/items/mart.asm), for * MARTTYPE_STANDARD only. Welcome, then BUY/SELL/QUIT until the player * quits; buying and selling loop over their own item lists until the * player backs out, then the menu asks "anything else?" and repeats. */ private *pokemart( martId: number, pages: string[], ): Generator { const r = this.reader; const write = (address: number) => { const content = readText(r, address, this.text()); if (content) pages.push(...content.split("\n\n")); }; const take = () => pages.splice(0, pages.length); const entry = r.near(SYMBOLS.marts + martId * 2, r.bank(SYMBOLS.marts)); let p = entry; const count = r.byte(p++); const stock = Array.from({ length: count }, () => r.byte(p++)); let first = true; for (;;) { const choice = yield { kind: "martmenu", prompt: readText( r, first ? SYMBOLS.martWelcomeText : SYMBOLS.martAskMoreText, this.text(), ), selected: 0, }; first = false; const option = typeof choice === "number" ? choice : 2; if (option === 0) yield* this.martBuy(stock, pages); else if (option === 1) yield* this.martSell(pages); else { write(SYMBOLS.martComeAgainText); if (pages.length) yield { kind: "text", pages: take() }; return; } } } /** BuyMenu/BuyMenuLoop: pick a stocked item, a 1-99 quantity, confirm the * price, then check funds and bag room before handing it over. */ private *martBuy( stock: number[], pages: string[], ): Generator { const r = this.reader, data = this.host.data; const write = (address: number) => { const content = readText(r, address, this.text()); if (content) pages.push(...content.split("\n\n")); }; const take = () => pages.splice(0, pages.length); const flush = function* (): Generator { if (pages.length) yield { kind: "text", pages: take() }; }; for (;;) { const entries: MartEntry[] = stock.map((item) => ({ item, name: data.itemName(item), price: data.itemPrice(item), })); const selection = yield { kind: "martlist", mode: "buy", entries, selected: 0, }; const item = typeof selection === "number" ? stock[selection] : undefined; if (item === undefined) return; const price = data.itemPrice(item); const chosen = yield { kind: "martquantity", mode: "buy", item, name: data.itemName(item), price, max: 99, prompt: readText(r, SYMBOLS.martHowManyText, this.text()), selected: 1, }; if (typeof chosen !== "number" || chosen < 1) continue; const total = price * chosen; this.strings[RAM.stringBuffer2] = data.itemName(item); this.numbers[RAM.itemQuantityChange] = chosen; this.numbers[RAM.moneyTemp] = total; write(SYMBOLS.martFinalPriceText); const confirmed = yield { kind: "yesno", pages: take() }; if (!confirmed) continue; if (total > this.state.money) { write(SYMBOLS.martNoMoneyText); yield* flush(); continue; } if (!receiveItem(data, this.state.bag, item, chosen)) { write(SYMBOLS.martPackFullText); yield* flush(); continue; } this.state.money -= total; write(SYMBOLS.martThanksText); yield* flush(); } } /** SellMenu/DepositSellPack: pick a bag item and a quantity up to its * stack, refuse unsellable items, then pay half price and toss it. */ private *martSell(pages: string[]): Generator { const r = this.reader, data = this.host.data; const write = (address: number) => { const content = readText(r, address, this.text()); if (content) pages.push(...content.split("\n\n")); }; const take = () => pages.splice(0, pages.length); const flush = function* (): Generator { if (pages.length) yield { kind: "text", pages: take() }; }; for (;;) { const slots = [...this.state.bag.items, ...this.state.bag.balls]; if (!slots.length) return; const entries: MartEntry[] = slots.map((slot) => ({ item: slot.item, name: data.itemName(slot.item), price: data.itemPrice(slot.item), owned: slot.quantity, })); const selection = yield { kind: "martlist", mode: "sell", entries, selected: 0, }; const slot = typeof selection === "number" ? slots[selection] : undefined; if (!slot) return; if (data.itemUnsellable(slot.item)) { write(SYMBOLS.martCantBuyText); yield* flush(); continue; } const price = data.itemPrice(slot.item); const chosen = yield { kind: "martquantity", mode: "sell", item: slot.item, name: data.itemName(slot.item), price, max: slot.quantity, prompt: readText(r, SYMBOLS.martSellHowManyText, this.text()), selected: 1, }; if (typeof chosen !== "number" || chosen < 1) continue; const total = Math.floor((price * chosen) / 2); this.strings[RAM.stringBuffer2] = data.itemName(slot.item); this.numbers[RAM.moneyTemp] = total; write(SYMBOLS.martSellPriceText); const confirmed = yield { kind: "yesno", pages: take() }; if (!confirmed) continue; tossItem(this.state.bag, slot.item, chosen); this.state.money = Math.min(C.MAX_MONEY, this.state.money + total); write(SYMBOLS.martBoughtText); yield* flush(); } } /** _PlayerDecorationMenu: owned categories, replacement and ornament sides. */ private *decorationMenu(): Generator< ScriptEffect, boolean, number | string | undefined > { const data = new DecorationData(this.reader, this.host.data); const categories = [ { slot: "bed", action: 1, index: 0 }, { slot: "carpet", action: 3, index: 1 }, { slot: "plant", action: 5, index: 2 }, { slot: "poster", action: 7, index: 3 }, { slot: "console", action: 9, index: 4 }, { slot: "left", action: 13, index: 5 }, { slot: "bigDoll", action: 11, index: 6 }, ] as const; let changed = false; for (;;) { const owned = categories .map((category) => ({ ...category, ids: data.owned(this.state.events, category.action), })) .filter((category) => category.ids.length); const names = owned.map((category) => { const p = OVERWORLD["_PlayerDecorationMenu.category_pointers"] + category.index * 4 + 2; return readString( this.reader, this.reader.near(p, this.reader.bank(p)), this.text(), ); }); const choice = yield { kind: "menu", pages: ["DECORATION"], items: [...names, "EXIT"], selected: 0, }; if (typeof choice !== "number" || choice < 0 || choice >= owned.length) return changed; const category = owned[choice]; const picked = yield { kind: "menu", pages: [names[choice]], items: [ ...category.ids.map((id) => data.name(id)), data.name(1), data.name(0), ], selected: 0, }; if ( typeof picked !== "number" || picked < 0 || picked > category.ids.length ) continue; const id = category.ids[picked] ?? 0; let slot: keyof Decorations = category.slot; if (slot === "left") { const side = yield { kind: "menu", pages: [ readText( this.reader, id ? OVERWORLD.WhichSidePutOnText : OVERWORLD.WhichSidePutAwayText, this.text(), ), ], items: ["RIGHT SIDE", "LEFT SIDE", "CANCEL"], selected: 0, }; if (side !== 0 && side !== 1) continue; slot = side === 0 ? "right" : "left"; } const old = this.state.decorations[slot]; let text: number; if (id === old) text = id ? OVERWORLD.AlreadySetUpText : OVERWORLD.NothingToPutAwayText; else { this.state.decorations[slot] = id; if (id && (slot === "left" || slot === "right")) { const other = slot === "left" ? "right" : "left"; if (this.state.decorations[other] === id) this.state.decorations[other] = 0; } changed = true; this.strings[RAM.stringBuffer3] = data.name(old || id); this.strings[RAM.stringBuffer4] = data.name(id); text = !id ? OVERWORLD.PutAwayTheDecoText : old ? OVERWORLD.PutAwayAndSetUpText : OVERWORLD.SetUpTheDecoText; } yield { kind: "text", pages: [readText(this.reader, text, this.text())] }; } } /** VAR_MOVEMENT: only the states GetPlayerSprite knows. */ private setPlayerState(state: number, offset: number, opcode: number) { if (![0, 1, 2, 4, 8].includes(state)) throw new UnsupportedScript(offset, opcode, " (player state)"); this.state.playerState = state; } /** CheckPartyMove: the first party member (not an Egg) knowing `move`. */ private partyMove(move: number): number { return this.state.party.findIndex( (mon) => mon.species !== OW.EGG && mon.moves.includes(move as never), ); } /** GetPartyNickname: wCurPartyMon's nickname into wStringBuffer1-3. */ private partyNickname() { const name = this.state.party[this.curPartyMon]?.nickname ?? ""; this.strings[RAM.stringBuffer1] = name; this.strings[RAM.stringBuffer2] = name; this.strings[RAM.stringBuffer3] = name; } /** An elevator's floors (elevfloor: floor, warp, map), from the count * that precedes them; names come from ElevatorFloorNames. */ private elevatorFloors(data: number) { const r = this.reader, count = r.byte(data); if (!count || count > 15) throw new Error("Invalid elevator data."); return Array.from({ length: count }, (_, index) => { const entry = data + 1 + index * 4, floor = r.byte(entry); const names = OVERWORLD.ElevatorFloorNames; return { name: readString( r, r.near(names + floor * 2, r.bank(names)), this.text(), ), warp: r.byte(entry + 1), map: `${r.byte(entry + 2)}:${r.byte(entry + 3)}`, }; }); } /** readvar: the script variables that native state can answer. */ private readVar(id: number, offset: number): number { const clock = this.host.clock(); const [group, number] = this.host.mapId.split(":").map(Number); switch (id) { case 0x01: return this.state.party.length; case 0x04: return this.host.timeOfDay(); case 0x07: { // VAR_BADGES: Johto and Kanto badges together. let badges = 0; for ( let flag = OW.ENGINE_ZEPHYRBADGE; flag < OW.ENGINE_ZEPHYRBADGE + 16; flag++ ) if (this.state.flags.has(flag)) badges++; return badges; } case 0x08: return this.state.playerState; case 0x0f: return this.host.environment(); case 0x05: return this.state.pokedex.caught.size & 0xff; case 0x06: return this.state.pokedex.seen.size & 0xff; case 0x09: return this.host.facing(); case 0x0a: return clock.hours; case 0x0b: return clock.weekday; case 0x0c: return group; case 0x0d: return number; case 0x10: // .BoxFreeSpace: MONS_PER_BOX minus sBoxCount. Nothing can be // deposited yet (catching with a full party is refused and the PC // is not ported), so the current box is empty. return MONS_PER_BOX; case 0x12: return this.host.position().x; case 0x13: return this.host.position().y; case 0x14: return this.state.specialPhoneCall; default: throw new UnsupportedScript(offset, 0x1c, ` (variable ${id})`); } } /** Native translations of special routines, selected by routine address. * `value` is the script's current wScriptVar, in case a routine (like * CheckPokerus) returns a new one; routines that leave it alone return it * unchanged. Returns undefined for routines that have not been translated. */ private *special( routine: number, value: number, pages: string[], take: () => string[], ): Generator { const r = this.reader; switch (routine) { case SPECIAL_ROUTINES.nameRival: { // The naming screen's own title; any pending text was already shown. const fallback = readString(r, SYMBOLS.rivalDefaultName, this.text()); const name = yield { kind: "name", pages: [ ...take(), readString(r, SYMBOLS.rivalNameString, this.text()), ], current: fallback, max: 7, }; if ( typeof name !== "string" || name.length > 7 || (name && !NICKNAME_PATTERN.test(name)) ) throw new Error("Invalid rival name."); // InitName: a name of zero or more spaces takes the default. this.state.rivalName = /^ *$/.test(name) ? fallback : name; return value; } case SPECIAL_ROUTINES.restartMapMusic: case SPECIAL_ROUTINES.fadeOutMusic: case SPECIAL_ROUTINES.fadeOutToWhite: case SPECIAL_ROUTINES.warpToSpawnPoint: case SPECIAL_ROUTINES.stubbedTrainerRankingsHealings: // Music and fades are not modeled; Safari/contest flags are unused. // StubbedTrainerRankings_Healings begins with `ret`: the Trainer // Rankings feature (Pokémon News) was cut, leaving a dead stub. return value; case OVERWORLD.UpdatePlayerSprite: case OVERWORLD.PlayMapMusic: case OVERWORLD.UpdateTimePals: // The native renderer draws the player from wPlayerState each frame; // music and palette fades are presentation. return value; case OVERWORLD.SurfStartStep: // slow_step in the facing direction, then step_end. this.movementBuffer = [0x08 | this.host.facing(), 0x47]; return value; case SPECIAL_ROUTINES.healMachineAnim: case SPECIAL_ROUTINES.fadeOutToBlack: case SPECIAL_ROUTINES.fadeInFromBlack: case SPECIAL_ROUTINES.reloadSpritesNoPalettes: // Graphics, palettes and sound only: wPartyCount/wScriptVar select // an animation sequence but nothing persists past it. return value; case SPECIAL_ROUTINES.healParty: // HealPartyMon: clear status, restore HP and every move's PP. for (const mon of this.state.party) { mon.status = 0; mon.hp = mon.stats[0]; mon.pp = mon.moves.map((move, slot) => { if (!move) return 0; const ups = mon.pp[slot] >> 6, base = this.host.data.move(move).pp; // ComputeMaxPP clamps each PP Up's bonus to 7, so a 40 PP move // maxes at 61 instead of overflowing the packed byte at 64. return ( (ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups) ); }) as PartyMon["pp"]; } return value; case SPECIAL_ROUTINES.checkPokerus: // _CheckPokerus: carry (here, a truthy return) if any party member's // status nybble is infected. No ported content ever sets pokerus, // so this is always 0 until Pokérus itself is modeled. return Number( this.state.party.some((mon) => (mon.pokerus & 0x0f) !== 0), ); case SPECIAL_ROUTINES.setDayOfWeek: { const names = Array.from({ length: 7 }, (_, day) => readString( r, r.near( SYMBOLS.weekdayNames + day * 2, r.bank(SYMBOLS.weekdayNames), ), this.text(), ).trim(), ); // Earlier text stays on screen until the weekday box replaces it. if (pages.length) yield { kind: "text", pages: take() }; for (;;) { const day = yield { kind: "weekday", pages: [readText(r, SYMBOLS.whatDayText, this.text())], names, }; if ( typeof day !== "number" || !Number.isInteger(day) || day < 0 || day > 6 ) throw new Error("Invalid weekday."); const confirmed = yield { kind: "yesno", pages: [names[day] + readText(r, SYMBOLS.isItDayText, this.text())], }; if (!confirmed) continue; this.state.clock.weekdayOffset = (day - this.host.deviceWeekday() + 7) % 7; return value; } } case SPECIAL_ROUTINES.initialSetDst: case SPECIAL_ROUTINES.initialClearDst: { const set = routine === SPECIAL_ROUTINES.initialSetDst; this.state.clock.dst = set; const { hours, minutes } = this.host.clock(); const hour = hours % 12 || 12; const time = `${hour}:${String(minutes).padStart(2, "0")} ${hours < 12 ? "AM" : "PM"}`; const text = readText( r, set ? SYMBOLS.dstConfirmText : SYMBOLS.timeConfirmText, this.text(), ); // The box is cleared first; the following yesorno shows this page. take(); pages.push(`${time}${text.startsWith(",") ? "" : " "}${text}`); return value; } case OVERWORLD.OverworldTownMap: yield { kind: "townmap" }; return value; case OVERWORLD.PlayersHousePC: { take(); const changed = yield* this.decorationMenu(); if (changed) yield { kind: "warp", map: this.host.mapId, ...this.host.position(), facing: DIRECTION_ORDER[this.host.facing()], }; return 0; } case SPECIAL_ROUTINES.toggleDecorationsVisibility: { const data = new DecorationData(r, this.host.data); const slots = ["console", "left", "right", "bigDoll"] as const; const events = [ EVENTS.playersHouseConsole, EVENTS.playersHouseDoll1, EVENTS.playersHouseDoll2, EVENTS.playersHouseBigDoll, ]; slots.forEach((slot, i) => { const id = this.state.decorations[slot]; if (id) { this.state.events.delete(events[i]); this.state.variables[i] = data.attribute(id).sprite; } else this.state.events.add(events[i]); }); return value; } case SPECIAL_ROUTINES.toggleMaptileDecorations: { const data = new DecorationData(r, this.host.data), deco = this.state.decorations; for (const [slot, x, y] of [ ["bed", 0, 4], ["plant", 7, 4], ["poster", 6, 0], ] as const) if (deco[slot]) yield { kind: "changeblock", x, y, block: data.attribute(deco[slot]).sprite, }; if (deco.poster) this.state.events.add(EVENTS.playersRoomPoster); else this.state.events.delete(EVENTS.playersRoomPoster); if (deco.carpet) { const block = data.attribute(deco.carpet).sprite; for (const [x, y, delta] of [ [0, 0, 0], [0, 2, 1], [2, 2, 2], [4, 2, 1], ]) yield { kind: "changeblock", x, y, block: block + delta }; } return value; } default: return undefined; } } } /** Run a script that must finish without presentation, such as in tests * and tooling. Pages are collected; any other effect is rejected. */ export function runScript( reader: RomReader, start: number, state: ScriptState, name: string, ): ScriptState & { pages: string[] } { const run = new ScriptRun(reader, start, state, { mapId: "", lastTalked: PLAYER, sceneOwner: () => undefined, timeOfDay: () => 1, clock: () => ({ weekday: 0, hours: 12, minutes: 0 }), deviceWeekday: () => 0, text: () => defaultTextContext(name), data: new PokemonData(reader), player: () => ({ id: 0, gender: 0 }), random: () => 0, landmark: () => 0, facing: () => 0, position: () => ({ x: 0, y: 0 }), objectPosition: () => undefined, playerCollision: () => 0, environment: () => 0, }); const pages: string[] = []; for (let effect = run.resume(); effect; effect = run.resume()) { if (effect.kind !== "text") throw new Error(`This script needs ${effect.kind} presentation.`); pages.push(...effect.pages); } return { ...run.state, pages }; }