import { RomReader } from "../rom/reader"; import { RAM } from "../rom/profile"; const CHARACTERS: Record = { 0x1f: " ", 0x22: "\n", // PlacePOKe: "". 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>; weekday: string; /** Numbers that text_decimal reads from RAM, by address. */ numbers?: Readonly>; /** Battle names for and : 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> = { 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."); }