Files
Crystal/src/game/scripts/interpreter.ts
T
Sébastien QUEROLandClaude Opus 5.5 796fe8f6d5
Checks / web (push) Canceled after 0s
Open Route 29 and Cherrygrove City
- Enable Route 29, its Route 46 gate, Cherrygrove City and its buildings.
- Run item balls through FindItemInBallScript and fruit trees through
  FruitTreeScript, with a native daily reset of the daily engine flags.
- Port the Pokémon Center nurse (HealParty with the PP Up clamp,
  CheckPokerus, phone-call checks) and record the whiteout spawn on
  entering a Pokémon Center, as EnterMapWarp does.
- Port standard Poké Marts with keyboard, D-pad and pointer menus.
- Add the recoil battle command (Struggle) and VAR_BOXSPACE.
- Save fruit trees, the daily reset day and the last spawn map; older
  version-3 backups migrate by absence.

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

1198 lines
41 KiB
TypeScript

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<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'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<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;
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 };
} = {},
) {
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<ScriptEffect, void, Input> {
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<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"].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: "<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 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<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();
}
}
/** 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<ScriptEffect, number | undefined, Input> {
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 };
}