Files
Crystal/src/game/scripts/interpreter.ts
T
Sébastien QUEROLandClaude Opus 5.5 451464f7c6
Checks / web (push) Canceled after 0s
Complete the overworld foundation
Port forced movement (turn delays, bumps, ice, currents, pits), dynamic
return warps, elevators, Surf, Cut, Strength and cycling rules, working
map blocks with changeblock/changemapblocks, and the remaining NPC
movement types and movement commands.

Add tileset animation from the ROM's command tables, grass sprite
priority and special map palettes; room decorations with ownership, the
bedroom PC and save migration; and Key Items access to the Bicycle.

Fourteen original-engine traces compare positions, facing, actors, event
flags and scripted poses. Tile animation matches original VRAM for four
tilesets, and LoadMapPals matches 192 palette cases. skyfall_top now
keeps the $60 sprite offset its step function leaves behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:47:29 +02:00

2039 lines
71 KiB
TypeScript

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<number>;
flags: Set<number>;
variables: Record<number, number>;
/** Scene values keyed by the map that owns the scene variable. */
scenes: Record<string, number>;
phone: number[];
clock: ClockSettings;
party: PartyMon[];
bag: Bag;
pokedex: { seen: Set<number>; caught: Set<number> };
/** wMoney: a 24-bit amount. */
money: number;
/** wFruitTreeFlags: trees (FRUITTREE_* ids) picked since the last reset. */
fruitTrees: Set<number>;
/** 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<number, string> = {};
/** Native values for text_decimal, keyed by RAM address (wItemQuantityChange,
* hMoneyTemp): the mart's quantity and price texts. */
private numbers: Record<number, number> = {};
/** 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<ScriptEffect, void, Input>;
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<ScriptEffect, void, Input> {
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<ScriptEffect, void, Input> {
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: "<NAME>'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<ScriptEffect, void, Input> {
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<ScriptEffect, void, Input> {
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<ScriptEffect, void, Input> {
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<ScriptEffect, void, Input> {
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<ScriptEffect, void, Input> {
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<ScriptEffect, number | undefined, Input> {
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 };
}