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.");
}