The sound engine (audio/engine.asm, home/audio.asm) is ported on a byte copy of its audio WRAM and a sound register model. A reference test runs the cartridge's routines beside it and matches the WRAM and registers after every UpdateSound for all songs, effects and cries, fades, the danger tone and effect priority. apu.ts synthesizes the four channels in an AudioWorklet; its output matches the reference emulator's APU by spectrum. The world runs the engine twice per tick; Play or the first press unlocks sound, pausing suspends it, Settings sets the volume. Wired in: map music by map setup and special map music, script audio commands, text fanfares, WaitSFX and cries that hold the script frozen (the last page stays shown), battle music, cries, fainting, victory, capture and the low HP tone, Pokémon Center healing, evolution, the intro, the Pokédex's cries, PCs, marts, healing items, talk and menu clicks, bumps, ledges, warps, boulders, the Bicycle and Surf. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
8.6 KiB
TypeScript
313 lines
8.6 KiB
TypeScript
import { RomReader } from "../rom/reader";
|
||
import { RAM } from "../rom/profile";
|
||
|
||
const CHARACTERS: Record<number, string> = {
|
||
0x1f: " ",
|
||
0x22: "\n",
|
||
// PlacePOKe: "<PO><KE>".
|
||
0x24: "POKé",
|
||
0x25: "",
|
||
0x4a: "Pokémon",
|
||
0x4b: "\n",
|
||
0x4c: "\n",
|
||
0x4e: "\n",
|
||
0x4f: "\n",
|
||
0x51: "\n\n",
|
||
0x54: "POKé",
|
||
0x55: "\n",
|
||
0x56: "……",
|
||
0x5b: "PC",
|
||
0x5c: "TM",
|
||
0x5d: "TRAINER",
|
||
0x5e: "ROCKET",
|
||
0x70: "PO",
|
||
0x71: "Ké",
|
||
0x72: "“",
|
||
0x73: "”",
|
||
0x74: "·",
|
||
0x75: "…",
|
||
0x7f: " ",
|
||
0x9a: "(",
|
||
0x9b: ")",
|
||
0x9c: ":",
|
||
0x9d: ";",
|
||
0x9e: "[",
|
||
0x9f: "]",
|
||
0xc0: "Ä",
|
||
0xc1: "Ö",
|
||
0xc2: "Ü",
|
||
0xc3: "ä",
|
||
0xc4: "ö",
|
||
0xc5: "ü",
|
||
0xd0: "'d",
|
||
0xd1: "'l",
|
||
0xd2: "'m",
|
||
0xd3: "'r",
|
||
0xd4: "'s",
|
||
0xd5: "'t",
|
||
0xd6: "'v",
|
||
0xdf: "←",
|
||
0xe0: "'",
|
||
0xe1: "POKé",
|
||
0xe2: "MON",
|
||
0xe3: "-",
|
||
0xe6: "?",
|
||
0xe7: "!",
|
||
0xe8: ".",
|
||
0xe9: "&",
|
||
0xea: "é",
|
||
0xeb: "→",
|
||
0xec: "▷",
|
||
0xed: "▶",
|
||
0xee: "▼",
|
||
0xef: "♂",
|
||
0xf0: "¥",
|
||
0xf1: "×",
|
||
0xf2: ".",
|
||
0xf3: "/",
|
||
0xf4: ",",
|
||
0xf5: "♀",
|
||
};
|
||
|
||
/** Values that original text reads from work RAM, supplied by native state. */
|
||
export interface TextContext {
|
||
player: string;
|
||
rival: string;
|
||
trainer?: 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 | undefined;
|
||
/** TextCommand_SOUND's sound effect (TextSFX), with the length of the
|
||
* text printed before it. */
|
||
onSound?: (sfx: number, at: number) => void;
|
||
}
|
||
|
||
/** TextSFX: the text commands that play a sound effect, and its id. */
|
||
const TEXT_SFX: Readonly<Record<number, number>> = {
|
||
0x0b: 0x00, // SFX_DEX_FANFARE_50_79
|
||
0x0e: 0x09, // SFX_DEX_FANFARE_20_49
|
||
0x0f: 0x01, // SFX_ITEM
|
||
0x10: 0x02, // SFX_CAUGHT_MON
|
||
0x11: 0x0a, // SFX_DEX_FANFARE_80_109
|
||
0x12: 0x2d, // SFX_FANFARE
|
||
0x13: 0x2c, // SFX_SLOT_MACHINE_START
|
||
};
|
||
|
||
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,
|
||
readonly opcode: number,
|
||
) {
|
||
super(
|
||
`Text command 0x${opcode.toString(16)} at 0x${offset.toString(16)} is not ported.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
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 === 0x3f) return context.trainer;
|
||
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,
|
||
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 = "",
|
||
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] ??
|
||
(address === RAM.playerName
|
||
? context.player
|
||
: address === RAM.rivalName
|
||
? context.rival
|
||
: address === RAM.momsName
|
||
? context.mom
|
||
: undefined);
|
||
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++);
|
||
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;
|
||
}
|
||
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 0x05:
|
||
case 0x07:
|
||
// TX_LOW writes on the box's lower line; TX_SCROLL starts a new one.
|
||
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:
|
||
break; // Timed pauses belong to presentation.
|
||
case 0x0b:
|
||
case 0x0e:
|
||
case 0x0f:
|
||
case 0x10:
|
||
case 0x11:
|
||
case 0x12:
|
||
case 0x13:
|
||
context.onSound?.(TEXT_SFX[command], output.length);
|
||
break;
|
||
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 before = output.length,
|
||
onSound = context.onSound;
|
||
const nested = readTextEnd(
|
||
reader,
|
||
reader.near(p, reader.byte(p + 2)),
|
||
onSound ? { ...context, onSound: (sfx, at) => onSound(sfx, before + at) } : context,
|
||
depth + 1,
|
||
);
|
||
output += nested.text;
|
||
prompt = nested.prompt;
|
||
p += 3;
|
||
break;
|
||
}
|
||
default:
|
||
throw new UnsupportedText(commandOffset, command);
|
||
}
|
||
}
|
||
throw new Error("Text command limit exceeded.");
|
||
}
|