Checks / web (push) Canceled after 0s
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>
2039 lines
71 KiB
TypeScript
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 };
|
|
}
|