Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 16:09:22 +02:00
1 parent 8b80d05ca2
commit c08d250cf6
39 files changed
+10727 -1524

No files matched your search

File diff suppressed because it is too large. Load diff
+75
View File
@@ -0,0 +1,75 @@
import { RomReader } from "../rom/reader";
import { DIRECTION_ORDER, type Direction } from "../world/types";
/** 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 }
/** Two tiles in two phases of `ticks` each, with a shadow and arc. */
| { kind: "jump"; direction: Direction; ticks: number }
| { kind: "sleep"; ticks: number }
| { kind: "fix-facing"; fixed: boolean }
| { kind: "visibility"; hidden: boolean }
| { kind: "emote"; shown: boolean };
export class UnsupportedMovement extends Error {
constructor(
readonly offset: number,
readonly command: number,
) {
super(
`Movement command 0x${command.toString(16)} at 0x${offset.toString(16)} is not ported.`,
);
}
}
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. */
export function readMovement(reader: RomReader, start: number): MovementStep[] {
const steps: MovementStep[] = [];
let p = start;
for (let count = 0; count < 256; count++) {
const offset = p,
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)
steps.push({
kind: "step",
direction,
ticks: STEP_TICKS[(command - 0x08) >> 2],
animate: true,
});
else if (command >= 0x14 && command < 0x20)
// Slides move without the walking animation (e.g. being pushed).
steps.push({
kind: "step",
direction,
ticks: STEP_TICKS[(command - 0x14) >> 2],
animate: false,
});
else if (command >= 0x2c && command < 0x38)
steps.push({
kind: "jump",
direction,
ticks: STEP_TICKS[(command - 0x2c) >> 2],
});
else if (command === 0x3a || command === 0x3b)
steps.push({ kind: "fix-facing", fixed: command === 0x3b });
else if (command === 0x3c || command === 0x3d)
steps.push({ kind: "visibility", hidden: command === 0x3d });
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++) });
else if (command === 0x47) return steps;
else if (command === 0x53 || command === 0x54)
steps.push({ kind: "emote", shown: command === 0x54 });
else throw new UnsupportedMovement(offset, command);
}
throw new Error("Movement data is unterminated.");
}
+185 -32
View File
@@ -1,17 +1,16 @@
import { RomReader } from "../rom/reader";
import { RAM } from "../rom/profile";
const CHARACTERS: Record<number, string> = {
0x1f: " ",
0x22: "\n",
0x25: "",
0x49: "MOM",
0x4a: "Pokémon",
0x4b: "\n",
0x4c: "\n",
0x4e: "\n",
0x4f: "\n",
0x51: "\n\n",
0x53: "???",
0x54: "POKé",
0x55: "\n",
0x56: "……",
@@ -68,6 +67,42 @@ const CHARACTERS: Record<number, string> = {
0xf5: "♀",
};
/** Values that original text reads from work RAM, supplied by native state. */
export interface TextContext {
player: string;
rival: string;
mom: string;
/** Native strings stored at the RAM addresses named by text_ram. */
strings: Readonly<Record<number, string>>;
weekday: string;
/** Numbers that text_decimal reads from RAM, by address. */
numbers?: Readonly<Record<number, number>>;
/** Battle names for <USER> and <TARGET>: whose turn it is, and the
* enemy prefix the game prints before the enemy's nickname. */
battle?: { turn: number; player: string; enemy: string; enemyPrefix: string };
/** Native handlers for text_asm, keyed by the command's ROM offset. They
* return where text continues, as the original routine returns in hl. */
asm?: (offset: number) => number;
}
export const defaultTextContext = (player: string): TextContext => ({
player,
// New games initialize these names before the player can choose them.
rival: "???",
mom: "MOM",
strings: {},
weekday: "",
});
/** text_buffer indices select these buffers (data/text_buffers.asm). */
const STRING_BUFFERS = [
RAM.stringBuffer3,
RAM.stringBuffer4,
RAM.stringBuffer5,
RAM.stringBuffer2,
RAM.stringBuffer1,
];
export class UnsupportedText extends Error {
constructor(
readonly offset: number,
@@ -79,49 +114,167 @@ export class UnsupportedText extends Error {
}
}
function battler(context: TextContext, enemy: boolean): string | undefined {
const names = context.battle;
if (!names) return undefined;
return enemy ? names.enemyPrefix + names.enemy : names.player;
}
function character(code: number, context: TextContext): string | undefined {
// PlaceMoveTargetsName / PlaceMoveUsersName
if (code === 0x59) return battler(context, context.battle?.turn === 0);
if (code === 0x5a) return battler(context, context.battle?.turn === 1);
if (code === 0x52 || code === 0x14) return context.player;
if (code === 0x53) return context.rival;
if (code === 0x49) return context.mom;
if (code >= 0x80 && code <= 0x99)
return String.fromCharCode(65 + code - 0x80);
if (code >= 0xa0 && code <= 0xb9)
return String.fromCharCode(97 + code - 0xa0);
if (code >= 0xf6) return String(code - 0xf6);
return CHARACTERS[code];
}
/** Decode a plain "@"-terminated string such as a name or item label. */
export function readString(
reader: RomReader,
start: number,
context: TextContext,
limit = 32,
): string {
let output = "";
for (let p = start; p < start + limit; p++) {
const code = reader.byte(p);
if (code === 0x50) return output;
const value = character(code, context);
if (value === undefined) throw new UnsupportedText(p, code);
output += value;
}
throw new Error("Unterminated string.");
}
export function readText(
reader: RomReader,
start: number,
playerName: string,
context: TextContext,
depth = 0,
): string {
return readTextEnd(reader, start, context, depth).text;
}
/** Read text and report whether it ended with `prompt`, which waits for a
* button in battle, rather than `done` or `text_end`. */
export function readTextEnd(
reader: RomReader,
start: number,
context: TextContext,
depth = 0,
): { text: string; prompt: boolean } {
if (depth > 8) throw new Error("Text calls exceed the recursion limit.");
let p = start,
output = "";
output = "",
prompt = false;
// Only the outermost text is trimmed; nested fragments keep their spacing.
const finish = (value: string) => (depth === 0 ? value.trim() : value);
const ram = (address: number) => {
const value = context.strings[address];
if (value === undefined)
throw new Error(
`Text reads RAM 0x${address.toString(16)}, which has no native value.`,
);
return value;
};
for (let count = 0; count < 8192; count++) {
const commandOffset = p,
command = reader.byte(p++);
if (command === 0x50) return output.trim();
if (command === 0x16) {
output += readText(
reader,
reader.near(p, reader.byte(p + 2)),
playerName,
depth + 1,
);
p += 3;
continue;
}
if (command !== 0) throw new UnsupportedText(commandOffset, command);
let ended = false;
for (let length = 0; length < 8192; length++) {
const offset = p,
code = reader.byte(p++);
if (code === 0x57 || code === 0x58) return output.trim();
if (code === 0x50) {
ended = true;
switch (command) {
case 0x50:
return { text: finish(output), prompt };
case 0x00: {
let ended = false;
for (let length = 0; length < 8192; length++) {
const offset = p,
code = reader.byte(p++);
if (code === 0x57 || code === 0x58)
return { text: finish(output), prompt: code === 0x58 };
if (code === 0x50) {
ended = true;
break;
}
const value = character(code, context);
if (value === undefined) throw new UnsupportedText(offset, code);
output += value;
}
if (!ended) throw new Error("Unterminated text.");
break;
}
if (code === 0x52 || code === 0x14) output += playerName;
else if (code >= 0x80 && code <= 0x99)
output += String.fromCharCode(65 + code - 0x80);
else if (code >= 0xa0 && code <= 0xb9)
output += String.fromCharCode(97 + code - 0xa0);
else if (code >= 0xf6) output += String(code - 0xf6);
else if (CHARACTERS[code] !== undefined) output += CHARACTERS[code];
else throw new UnsupportedText(offset, code);
case 0x01:
output += ram(reader.word(p));
p += 2;
break;
case 0x06:
case 0x0d:
// Button waits inside one message become page boundaries in the DOM.
output = output.trimEnd() + "\n\n";
break;
case 0x07:
output += "\n";
break;
case 0x08: {
const next = context.asm?.(commandOffset);
if (next === undefined) throw new UnsupportedText(commandOffset, command);
p = next;
break;
}
case 0x09: {
const address = reader.word(p);
p += 3; // address, then bytes and digits (left-aligned printing)
const value = context.numbers?.[address];
if (value === undefined)
throw new Error(
`Text reads number 0x${address.toString(16)}, which has no native value.`,
);
output += String(value);
break;
}
case 0x0a:
case 0x0b:
case 0x0e:
case 0x0f:
case 0x10:
case 0x11:
case 0x12:
case 0x13:
break; // Timed pauses and sound cues belong to presentation/audio.
case 0x0c:
output += "…".repeat(reader.byte(p++));
break;
case 0x14: {
const index = reader.byte(p++);
const address = STRING_BUFFERS[index];
if (address === undefined)
throw new UnsupportedText(commandOffset, command);
output += ram(address);
break;
}
case 0x15:
output += context.weekday;
break;
case 0x16: {
const nested = readTextEnd(
reader,
reader.near(p, reader.byte(p + 2)),
context,
depth + 1,
);
output += nested.text;
prompt = nested.prompt;
p += 3;
break;
}
default:
throw new UnsupportedText(commandOffset, command);
}
if (!ended) throw new Error("Unterminated text.");
}
throw new Error("Text command limit exceeded.");
}