Add native Crystal exploration port and reference tooling
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 09:23:29 +02:00
1 parent 2127f5f459
commit 8b80d05ca2
56 files changed
+6910 -1

No files matched your search

+208
View File
@@ -0,0 +1,208 @@
import { RomReader } from "../rom/reader";
import { SYMBOLS } from "../rom/profile";
import { readText } from "./text";
export interface ScriptState {
events: Set<number>;
flags: Set<number>;
variables: Record<number, number>;
}
export interface ScriptResult extends ScriptState {
pages: string[];
facePlayer: boolean;
turns: { object: number; direction: number }[];
}
export class UnsupportedScript extends Error {
constructor(
readonly offset: number,
readonly opcode: number,
) {
super(
`Script command 0x${opcode.toString(16)} at 0x${offset.toString(16)} is not ported.`,
);
}
}
/** A native interpreter for the game's own event format, NOT GB assembly.
* Evaluate on a private draft. Unknown commands abort the whole interaction;
* callers commit only after successful completion and dialogue acknowledgement. */
export function runScript(
reader: RomReader,
start: number,
state: ScriptState,
name: string,
): ScriptResult {
const result: ScriptResult = {
events: new Set(state.events),
flags: new Set(state.flags),
variables: { ...state.variables },
pages: [],
facePlayer: false,
turns: [],
};
const stack: number[] = [];
let p = start,
value = 0;
const byte = () => reader.byte(p++);
const word = () => {
const value = reader.word(p);
p += 2;
return value;
};
const near = () => {
const value = reader.near(p, reader.bank(p));
p += 2;
return value;
};
const far = () => {
const value = reader.far(p);
p += 3;
return value;
};
const text = (address: number) => {
const content = readText(reader, address, name);
if (content) result.pages.push(...content.split("\n\n"));
if (result.pages.length > 64) throw new Error("Too many dialogue pages.");
};
for (let steps = 0; steps < 2048; steps++) {
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 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 = reader.far(SYMBOLS.standardScripts + index * 3);
break;
}
case 0x15:
value = byte();
break;
case 0x16:
value = (value + byte()) & 255;
break;
case 0x31:
value = Number(result.events.has(word()));
break;
case 0x32:
result.events.delete(word());
break;
case 0x33:
result.events.add(word());
break;
case 0x34:
value = Number(result.flags.has(word()));
break;
case 0x35:
result.flags.delete(word());
break;
case 0x36:
result.flags.add(word());
break;
case 0x47:
case 0x49:
case 0x54:
case 0x55:
break; // handled by native dialogue presentation
case 0x4b:
text(far());
break;
case 0x4c:
text(near());
break;
case 0x51:
result.facePlayer = true;
text(near());
return result;
case 0x52:
text(far());
return result;
case 0x53:
text(near());
return result;
case 0x6b:
result.facePlayer = true;
break;
case 0x6d: {
const sprite = byte();
result.variables[sprite] = byte();
break;
}
case 0x76:
result.turns.push({ object: byte(), direction: byte() });
break;
// Audio and delays are presentation events. Native sound sequencing is a
// separate pending subsystem; consuming their operands keeps data aligned.
case 0x7f:
case 0x85:
word();
break;
case 0x81:
word();
byte();
break;
case 0x82:
case 0x83:
case 0x86:
case 0x87:
case 0x88:
break;
case 0x8b:
byte();
break;
case 0x90:
case 0x91:
if (!stack.length) return result;
p = stack.pop()!;
break;
case 0x93:
return result;
default:
throw new UnsupportedScript(offset, opcode);
}
if (stack.length > 16) throw new Error("Script call depth exceeded.");
}
throw new Error("Script instruction budget exceeded.");
}
+127
View File
@@ -0,0 +1,127 @@
import { RomReader } from "../rom/reader";
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: "……",
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: "♀",
};
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.`,
);
}
}
export function readText(
reader: RomReader,
start: number,
playerName: string,
depth = 0,
): string {
if (depth > 8) throw new Error("Text calls exceed the recursion limit.");
let p = start,
output = "";
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;
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);
}
if (!ended) throw new Error("Unterminated text.");
}
throw new Error("Text command limit exceeded.");
}