import { RomReader } from "../rom/reader"; import { EMPTY_ROUTINES, ENGINE_FLAGS, NATIVE_ROUTINES, NUM_FRUIT_TREES, EVENTS, 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, generatePartyMon, type PartyMon, } from "../party/pokemon"; import { DIRECTION_ORDER, type Direction } from "../world/types"; 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 { 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's low byte (checkphonecall): nonzero while a call * is pending. No ported content ever sets it, so it is not saved. */ specialPhoneCall: number; } export const emptyScriptState = (): ScriptState => ({ 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, }); export const copyScriptState = (state: ScriptState): ScriptState => ({ 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, }); /** 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 = /** 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 } } /** newloadmap: the world enters this map after the script ends. */ | { kind: "warp"; map: string; x: number; y: 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 }; } 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; /** 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; 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 }; } = {}, ) { this.state = copyScriptState(state); this.lastTalked = host.lastTalked; 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(), 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; // 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"].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 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.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 (!Object.values(EMPTY_ROUTINES).includes(routine as never)) throw new UnsupportedScript(offset, opcode); break; } case 0x19: { // readmem: only the fruit a tree bears is readable so far. if (word() !== RAM.curFruit) throw new UnsupportedScript(offset, opcode); value = this.fruit; 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 0x5b: break; // randomwildmon clears wBattleScriptFlags. case 0x5f: { if (!this.options.wild) throw new UnsupportedScript(offset, opcode, " (trainer battle)"); const result = yield synchronous({ kind: "battle", wild: this.options.wild }); if (typeof result !== "number") throw new Error("Invalid battle result."); battleResult = result; break; } case 0x60: // Script_reloadmapafterbattle: a loss continues with the whiteout. if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript; 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 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(); // SetCaughtData: time of day and level, then gender and landmark. const caughtData = ((((((this.host.timeOfDay() + 1) & 3) << 6) | level) & 0xff) << 8) | (player.gender << 7) | this.host.landmark(); const mon = generatePartyMon(data, { species, level, item, otId: player.id, ot: this.host.text().player, dvs, caughtData, }); 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 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; const 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 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; } // Audio commands are consumed; native sound sequencing is pending. case 0x7f: case 0x84: case 0x85: word(); break; case 0x81: word(); byte(); break; 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(); } } /** 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 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 (no capture or PC), so the current box is empty. return MONS_PER_BOX; case 0x12: return this.host.position().x; case 0x13: return this.host.position().y; 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.restartMapMusic: 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 SPECIAL_ROUTINES.healMachineAnim: // 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 SPECIAL_ROUTINES.toggleDecorationsVisibility: // No decorations are placed until the native decoration system exists, // so every decoration object is hidden, as with empty wDeco* values. for (const event of [ EVENTS.playersHouseConsole, EVENTS.playersHouseDoll1, EVENTS.playersHouseDoll2, EVENTS.playersHouseBigDoll, ]) this.state.events.add(event); return value; case SPECIAL_ROUTINES.toggleMaptileDecorations: // Empty decoration slots leave the room blocks unchanged and clear // the poster's conditional event. this.state.events.delete(EVENTS.playersRoomPoster); 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 }), }); 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 }; }