Complete the overworld foundation
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>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-25 16:47:29 +02:00
1 parent 9ac111f262
commit 451464f7c6
38 files changed
+5600 -490

No files matched your search

+615 -42
View File
@@ -1,3 +1,8 @@
import {
DecorationData,
emptyDecorations,
type Decorations,
} from "../world/decorations";
import { RomReader } from "../rom/reader";
import {
EMPTY_ROUTINES,
@@ -5,6 +10,7 @@ import {
NATIVE_ROUTINES,
NUM_FRUIT_TREES,
EVENTS,
OVERWORLD,
RAM,
SPECIAL_ROUTINES,
SYMBOLS,
@@ -28,6 +34,7 @@ import {
} 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,
@@ -42,6 +49,7 @@ export interface ClockSettings {
dst: boolean;
}
export interface ScriptState {
decorations: Decorations;
events: Set<number>;
flags: Set<number>;
variables: Record<number, number>;
@@ -66,8 +74,18 @@ export interface ScriptState {
/** 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: {},
@@ -83,8 +101,13 @@ export const emptyScriptState = (): ScriptState => ({
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 },
@@ -103,6 +126,10 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
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
@@ -121,6 +148,7 @@ export interface MartEntry {
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 }
@@ -144,7 +172,12 @@ export type ScriptEffect =
| { 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 }
| {
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. */
| {
@@ -161,13 +194,31 @@ export type ScriptEffect =
| {
kind: "battle";
wild?: { species: number; level: number };
trainer?: { classId: number; id: number; winText?: number; lossText?: number };
trainer?: {
classId: number;
id: number;
winText?: number;
lossText?: number;
};
canLose?: boolean;
/** catchtutorial: the Dude's demonstration (BATTLETYPE_TUTORIAL). */
tutorial?: boolean;
}
/** newloadmap: the world enters this map after the script ends. */
| { kind: "warp"; map: string; x: number; y: number };
/** 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 {
@@ -192,6 +243,9 @@ export interface ScriptHost {
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 {
@@ -240,7 +294,16 @@ export function trainerWalkBuffer(
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]];
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
@@ -274,8 +337,11 @@ export class ScriptRun {
/** 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. */
/** 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;
@@ -299,10 +365,30 @@ export class ScriptRun {
/** 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);
@@ -384,7 +470,15 @@ export class ScriptRun {
const synchronous = (effect: ScriptEffect) => {
if (
this.options.callback &&
!["turn", "appear", "disappear", "moveobject"].includes(effect.kind)
![
"turn",
"appear",
"disappear",
"moveobject",
"changeblock",
"mapblocks",
"stonetable",
].includes(effect.kind)
)
throw new Error("Map callbacks cannot present scenes.");
return effect;
@@ -394,7 +488,11 @@ export class ScriptRun {
// 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++)
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;
};
@@ -421,7 +519,10 @@ export class ScriptRun {
// 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)
if (
address !== RAM.callerContact + PHONE_CONTACT_SCRIPT2_BANK ||
script === undefined
)
throw new UnsupportedScript(offset, opcode);
stack.push(p);
p = script;
@@ -526,21 +627,26 @@ export class ScriptRun {
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)");
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) {
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)");
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;
@@ -560,6 +666,41 @@ export class ScriptRun {
// 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;
@@ -570,6 +711,13 @@ export class ScriptRun {
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;
}
@@ -586,19 +734,42 @@ export class ScriptRun {
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.");
if (tree < 1 || tree > NUM_FRUIT_TREES)
throw new Error("Invalid fruit tree.");
this.fruitTree = tree;
p = SYMBOLS.fruitTreeScript;
break;
}
case 0x1e: {
// Only VAR_BATTLETYPE's normal and CANLOSE modes are translated.
const variable = byte(), setting = byte();
// 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)");
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;
@@ -621,7 +792,9 @@ export class ScriptRun {
case 0x62: {
// trainertext: seen, beaten or loss text from wTempTrainer.
const header = temp(offset, opcode),
text = [header.seenText, header.beatenText, header.lostScript][byte()];
text = [header.seenText, header.beatenText, header.lostScript][
byte()
];
if (!text) throw new UnsupportedScript(offset, opcode, " (text)");
write(text);
break;
@@ -640,7 +813,8 @@ export class ScriptRun {
}
case 0x65: {
const after = temp(offset, opcode).afterScript;
if (!after) throw new UnsupportedScript(offset, opcode, " (no script)");
if (!after)
throw new UnsupportedScript(offset, opcode, " (no script)");
p = after;
break;
}
@@ -660,19 +834,25 @@ export class ScriptRun {
break;
case 0x64: {
// Null pointers request the battle engine's default end text.
const bank = r.bank(p), won = word(), lost = word();
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)");
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,
kind: "battle",
wild,
trainer: trainer && { ...trainer, winText, lossText },
canLose,
});
if (typeof result !== "number") throw new Error("Invalid battle result.");
if (typeof result !== "number")
throw new Error("Invalid battle result.");
battleResult = result;
value = result & 0x3f; // Script_startbattle also updates wScriptVar.
break;
@@ -719,6 +899,91 @@ export class ScriptRun {
// 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;
@@ -901,6 +1166,32 @@ export class ScriptRun {
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();
@@ -960,7 +1251,8 @@ export class ScriptRun {
if (r.word(p) === RAM.movementBuffer) {
word();
const buffer = this.movementBuffer;
if (!buffer) throw new UnsupportedScript(offset, opcode, " (movement buffer)");
if (!buffer)
throw new UnsupportedScript(offset, opcode, " (movement buffer)");
steps = readMovement({ byte: (index) => buffer[index] }, 0);
} else steps = readMovement(r, near());
yield* flush();
@@ -1015,7 +1307,11 @@ export class ScriptRun {
position = this.host.objectPosition(target);
if (target === PLAYER || !position)
throw new UnsupportedScript(offset, opcode, " (object)");
yield synchronous({ kind: "moveobject", object: target, ...position });
yield synchronous({
kind: "moveobject",
object: target,
...position,
});
break;
}
case 0x75: {
@@ -1038,6 +1334,45 @@ export class ScriptRun {
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:
@@ -1159,7 +1494,12 @@ export class ScriptRun {
name: data.itemName(item),
price: data.itemPrice(item),
}));
const selection = yield { kind: "martlist", mode: "buy", entries, selected: 0 };
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);
@@ -1219,7 +1559,12 @@ export class ScriptRun {
price: data.itemPrice(slot.item),
owned: slot.quantity,
}));
const selection = yield { kind: "martlist", mode: "sell", entries, selected: 0 };
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)) {
@@ -1252,6 +1597,152 @@ export class ScriptRun {
}
}
/** _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();
@@ -1261,6 +1752,21 @@ export class ScriptRun {
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:
@@ -1308,11 +1814,18 @@ export class ScriptRun {
const fallback = readString(r, SYMBOLS.rivalDefaultName, this.text());
const name = yield {
kind: "name",
pages: [...take(), readString(r, SYMBOLS.rivalNameString, this.text())],
pages: [
...take(),
readString(r, SYMBOLS.rivalNameString, this.text()),
],
current: fallback,
max: 7,
};
if (typeof name !== "string" || name.length > 7 || (name && !NICKNAME_PATTERN.test(name)))
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;
@@ -1327,6 +1840,16 @@ export class ScriptRun {
// 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:
@@ -1345,7 +1868,9 @@ export class ScriptRun {
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);
return (
(ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups)
);
}) as PartyMon["pp"];
}
return value;
@@ -1409,22 +1934,68 @@ export class ScriptRun {
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 [
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,
])
this.state.events.add(event);
];
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:
// Empty decoration slots leave the room blocks unchanged and clear
// the poster's conditional event.
this.state.events.delete(EVENTS.playersRoomPoster);
}
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;
}
@@ -1454,6 +2025,8 @@ export function runScript(
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()) {
+104 -9
View File
@@ -1,18 +1,64 @@
import { RomReader } from "../rom/reader";
import { DIRECTION_ORDER, type Direction } from "../world/types";
/** Poses a movement command holds for a number of ticks (OBJECT_ACTION and
* the step functions that drive it in engine/overworld/map_objects.asm). */
export type MovementPose =
/** step_dig: SetFacingCounterclockwiseSpin in place. */
| "spin"
/** return_dig: StepFunction_DigTo alternates spinning and flickering. */
| "spin-flicker"
/** rock_smash: StepFunction_RockSmash flickers the object. */
| "flicker"
/** tree_shake: OBJECT_ACTION_WEIRD_TREE. */
| "tree"
/** fish_got_bite: StepFunction_GotBite jiggles the rod. */
| "fish-bite"
/** fish_cast_rod: OBJECT_ACTION_FISHING for one tick. */
| "fish-cast"
/** skyfall: invisible for 15 ticks, then falls from above in 16. */
| "skyfall"
/** skyfall_top: 16 ticks in the air (SetFacingSkyfall). */
| "skyfall-top"
/** teleport_from: spins for 16 ticks, then spins while rising for 16. */
| "teleport-from"
/** teleport_to: waits 16, descends spinning 16, then spins 16. */
| "teleport-to";
/** One command from the game's own movement data (macros/scripts/movement.asm).
* Durations are overworld ticks: the original loop advances objects once per
* two video frames. Commands without a duration take effect immediately. */
export type MovementStep =
| { kind: "turn"; direction: Direction; ticks: 1 }
| { kind: "step"; direction: Direction; ticks: number; animate: boolean }
/** turn_step: StepFunction_Turn faces the new direction after one tick
* and reads the next command on the fourth. */
| { kind: "turn-step"; direction: Direction; ticks: 3 }
/** Steps, slides (`animate` false) and the spinning turn_away, turn_in and
* turn_waterfall steps (`spin`). */
| { kind: "step"; direction: Direction; ticks: number; animate: boolean; spin?: boolean }
/** Two tiles in two phases of `ticks` each, with a shadow and arc. */
| { kind: "jump"; direction: Direction; ticks: number }
| { kind: "sleep"; ticks: number }
/** A pose held in place for `ticks`. */
| { kind: "pose"; pose: MovementPose; ticks: number }
/** step_bump: one tick walking in place. */
| { kind: "bump"; ticks: 1 }
| { kind: "fix-facing"; fixed: boolean }
/** set_sliding/remove_sliding: SLIDING_F shows no walking frames. */
| { kind: "sliding"; sliding: boolean }
| { kind: "visibility"; hidden: boolean }
| { kind: "emote"; shown: boolean };
| { kind: "emote"; shown: boolean }
/** step_shake: ShakeScreen with the given displacement byte. */
| { kind: "shake"; displacement: number }
/** step_wait_end: the script continues at once; the object sleeps, then
* returns to its own movement. */
| { kind: "wait-end"; ticks: number }
/** remove_object: DeleteMapObject; the script continues. */
| { kind: "remove" }
/** step_stop: the object stands for good; the script continues. */
| { kind: "stop" }
/** step_loop: start the movement data over, forever. */
| { kind: "loop" };
export class UnsupportedMovement extends Error {
constructor(
@@ -27,9 +73,10 @@ export class UnsupportedMovement extends Error {
const STEP_TICKS = [16, 8, 4] as const; // slow, walk, big (bike speed)
/** Decode movement data until step_end. Unknown commands abort the script
* rather than approximating a jump, slide or special effect. The source is
* usually the ROM; wMovementBuffer data built natively decodes the same way. */
/** Decode movement data until step_end or another command that ends it.
* Unknown commands abort the script rather than approximating an effect.
* The source is usually the ROM; wMovementBuffer data built natively
* decodes the same way. */
export function readMovement(
reader: Pick<RomReader, "byte">,
start: number,
@@ -41,14 +88,16 @@ export function readMovement(
command = reader.byte(p++);
const direction = DIRECTION_ORDER[command & 3];
if (command < 0x04) steps.push({ kind: "turn", direction, ticks: 1 });
else if (command >= 0x08 && command < 0x14)
else if (command < 0x08)
steps.push({ kind: "turn-step", direction, ticks: 3 });
else if (command < 0x14)
steps.push({
kind: "step",
direction,
ticks: STEP_TICKS[(command - 0x08) >> 2],
animate: true,
});
else if (command >= 0x14 && command < 0x20)
else if (command < 0x20)
// Slides move without the walking animation (e.g. being pushed).
steps.push({
kind: "step",
@@ -56,12 +105,23 @@ export function readMovement(
ticks: STEP_TICKS[(command - 0x14) >> 2],
animate: false,
});
else if (command >= 0x2c && command < 0x38)
else if (command < 0x2c)
// turn_away, turn_in, turn_waterfall: TurningStep spins while moving.
steps.push({
kind: "step",
direction,
ticks: STEP_TICKS[(command - 0x20) >> 2],
animate: true,
spin: true,
});
else if (command < 0x38)
steps.push({
kind: "jump",
direction,
ticks: STEP_TICKS[(command - 0x2c) >> 2],
});
else if (command === 0x38 || command === 0x39)
steps.push({ kind: "sliding", sliding: command === 0x39 });
else if (command === 0x3a || command === 0x3b)
steps.push({ kind: "fix-facing", fixed: command === 0x3b });
else if (command === 0x3c || command === 0x3d)
@@ -69,10 +129,45 @@ export function readMovement(
else if (command >= 0x3e && command <= 0x45)
steps.push({ kind: "sleep", ticks: command - 0x3d });
else if (command === 0x46)
steps.push({ kind: "sleep", ticks: reader.byte(p++) });
steps.push({ kind: "sleep", ticks: reader.byte(p++) || 256 });
else if (command === 0x47) return steps;
else if (command === 0x48) {
steps.push({ kind: "wait-end", ticks: reader.byte(p++) || 256 });
return steps;
} else if (command === 0x49) {
steps.push({ kind: "remove" });
return steps;
} else if (command === 0x4a) {
steps.push({ kind: "loop" });
return steps;
} else if (command === 0x4b) {
steps.push({ kind: "stop" });
return steps;
} else if (command === 0x4c)
steps.push({ kind: "pose", pose: "teleport-from", ticks: 32 });
else if (command === 0x4d)
steps.push({ kind: "pose", pose: "teleport-to", ticks: 49 });
else if (command === 0x4e)
steps.push({ kind: "pose", pose: "skyfall", ticks: 31 });
else if (command === 0x4f)
steps.push({ kind: "pose", pose: "spin", ticks: reader.byte(p++) || 256 });
else if (command === 0x50) steps.push({ kind: "bump", ticks: 1 });
else if (command === 0x51)
steps.push({ kind: "pose", pose: "fish-bite", ticks: 8 });
else if (command === 0x52)
steps.push({ kind: "pose", pose: "fish-cast", ticks: 1 });
else if (command === 0x53 || command === 0x54)
steps.push({ kind: "emote", shown: command === 0x54 });
else if (command === 0x55)
steps.push({ kind: "shake", displacement: reader.byte(p++) });
else if (command === 0x56)
steps.push({ kind: "pose", pose: "tree", ticks: 24 });
else if (command === 0x57)
steps.push({ kind: "pose", pose: "flicker", ticks: reader.byte(p++) || 256 });
else if (command === 0x58)
steps.push({ kind: "pose", pose: "spin-flicker", ticks: reader.byte(p++) || 256 });
else if (command === 0x59)
steps.push({ kind: "pose", pose: "skyfall-top", ticks: 16 });
else throw new UnsupportedMovement(offset, command);
}
throw new Error("Movement data is unterminated.");