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

+36
View File
@@ -0,0 +1,36 @@
import type { Palette } from "../world/types";
/** Decode Game Boy planar pixels directly to RGBA; no display hardware emulation. */
export function drawTile(
target: Uint8ClampedArray,
width: number,
height: number,
graphics: Uint8Array,
tile: number,
x: number,
y: number,
palette: Palette,
options: { transparent?: boolean; flipX?: boolean; flipY?: boolean } = {},
) {
const offset = tile * 16;
if (offset < 0 || offset + 16 > graphics.length) return;
for (let row = 0; row < 8; row++) {
const sourceRow = options.flipY ? 7 - row : row;
const lo = graphics[offset + sourceRow * 2],
hi = graphics[offset + sourceRow * 2 + 1];
for (let col = 0; col < 8; col++) {
const bit = options.flipX ? col : 7 - col;
const index = ((lo >> bit) & 1) | (((hi >> bit) & 1) << 1);
if (options.transparent && index === 0) continue;
const px = x + col,
py = y + row;
if (px < 0 || py < 0 || px >= width || py >= height) continue;
const destination = (py * width + px) * 4,
color = palette[index];
target[destination] = color[0];
target[destination + 1] = color[1];
target[destination + 2] = color[2];
target[destination + 3] = 255;
}
}
}
+137
View File
@@ -0,0 +1,137 @@
import { CrystalAssets } from "../rom/assets";
import { GameWorld } from "../world/world";
import type { Direction } from "../world/types";
import { drawTile } from "./pixels";
export class WorldRenderer {
readonly width = 160;
readonly height = 144;
private context: CanvasRenderingContext2D;
private image: ImageData;
constructor(
private assets: CrystalAssets,
canvas: HTMLCanvasElement,
) {
canvas.width = this.width;
canvas.height = this.height;
const context = canvas.getContext("2d", { alpha: false });
if (!context) throw new Error("Canvas rendering is unavailable.");
this.context = context;
this.image = context.createImageData(this.width, this.height);
}
render(
world: GameWorld,
playerX = world.x * 16,
playerY = world.y * 16,
frame = 0,
) {
const map = world.map,
tileset = this.assets.tileset(map),
palettes = this.assets.palettes(map, world.timeOfDay);
// Keep the camera player-centered as the original engine does. Out-of-map
// pixels use the map's own border block, including small room padding.
const cameraX = Math.round(playerX - 72),
cameraY = Math.round(playerY - 64);
this.image.data.fill(255);
for (
let tileY = Math.floor(cameraY / 8);
tileY < Math.ceil((cameraY + this.height) / 8);
tileY++
) {
for (
let tileX = Math.floor(cameraX / 8);
tileX < Math.ceil((cameraX + this.width) / 8);
tileX++
) {
const block = this.assets.block(
map,
Math.floor(tileX / 2),
Math.floor(tileY / 2),
);
const localX = ((tileX % 4) + 4) % 4,
localY = ((tileY % 4) + 4) % 4;
const encoded = tileset.metatiles[block * 16 + localY * 4 + localX];
const packed = tileset.paletteMap[encoded >> 1],
attribute = encoded & 1 ? packed >> 4 : packed & 15;
const tile = (encoded & 0x7f) + (attribute & 8 ? 0x60 : 0);
drawTile(
this.image.data,
this.width,
this.height,
tileset.graphics,
tile,
tileX * 8 - cameraX,
tileY * 8 - cameraY,
palettes[attribute & 7],
);
}
}
const actors = world
.objects()
.map((object) => ({
x: object.x * 16,
y: object.y * 16,
sprite: object.sprite,
direction: world.objectDirection(object),
palette: object.palette ? object.palette - 1 : undefined,
frame: 0,
}));
actors.push({
x: playerX,
y: playerY,
sprite: world.avatar === "kris" ? 0x60 : 1,
direction: world.direction,
palette: undefined,
frame,
});
actors.sort((a, b) => a.y - b.y);
for (const actor of actors)
this.actor(
actor.sprite,
Math.round(actor.x - cameraX),
Math.round(actor.y - cameraY - 4),
actor.direction,
actor.frame,
world.timeOfDay,
actor.palette,
);
this.context.putImageData(this.image, 0, 0);
}
private actor(
id: number,
x: number,
y: number,
direction: Direction,
frame: number,
time: number,
paletteIndex?: number,
) {
const sprite = this.assets.sprite(id);
if (!sprite) return;
const palette = this.assets.objectPalette(
paletteIndex ?? sprite.palette,
time,
);
for (const piece of this.assets.facing(
direction,
sprite.walking ? frame : 0,
)) {
const tile = piece.tile >= 0x80 ? piece.tile - 0x80 + 12 : piece.tile;
drawTile(
this.image.data,
this.width,
this.height,
sprite.graphics,
tile,
x + piece.x,
y + piece.y,
palette,
{
transparent: true,
flipX: Boolean(piece.attributes & 0x20),
flipY: Boolean(piece.attributes & 0x40),
},
);
}
}
}
+245
View File
@@ -0,0 +1,245 @@
import { RomReader } from "./reader";
import { decompress } from "./lz";
import { NATIVE_MAPS, SYMBOLS } from "./profile";
import type {
Direction,
GameMap,
Tileset,
Palette,
Sprite,
FacingTile,
} from "../world/types";
/** The only layer that knows where Crystal stores its data. Everything is
* decoded from the supplied ROM, on demand, and cached only in memory. */
export class CrystalAssets {
readonly reader: RomReader;
private maps = new Map<string, GameMap>();
private tilesets = new Map<string, Tileset>();
private sprites = new Map<number, Sprite>();
constructor(bytes: Uint8Array) {
this.reader = new RomReader(bytes);
}
map(id: string): GameMap {
const cached = this.maps.get(id);
if (cached) return cached;
if (!Object.hasOwn(NATIVE_MAPS, id))
throw new Error("This area has not been ported yet.");
const [group, number] = id.split(":").map(Number);
const r = this.reader;
const header =
r.near(SYMBOLS.mapGroups + (group - 1) * 2, r.bank(SYMBOLS.mapGroups)) +
(number - 1) * 9;
const attributes = r.near(header + 3, r.byte(header));
const blockWidth = r.byte(attributes + 2),
blockHeight = r.byte(attributes + 1);
if (!blockWidth || !blockHeight || blockWidth > 64 || blockHeight > 64)
throw new Error("Invalid map dimensions.");
const scriptBank = r.byte(attributes + 6);
let p = r.near(attributes + 9, scriptBank) + 2;
const map: GameMap = {
id,
group,
number,
name: NATIVE_MAPS[id],
width: blockWidth * 2,
height: blockHeight * 2,
border: r.byte(attributes),
tileset: r.byte(header + 1),
environment: r.byte(header + 2),
paletteMode: r.byte(header + 7) & 15,
blocks: r.slice(r.far(attributes + 3), blockWidth * blockHeight),
warps: [],
backgroundEvents: [],
objects: [],
connections: [],
sceneScripts: [],
callbacks: [],
coordinateEvents: [],
};
const warpCount = r.byte(p++);
for (let i = 0; i < warpCount; i++, p += 5) {
map.warps.push({
y: r.byte(p),
x: r.byte(p + 1),
targetWarp: r.byte(p + 2),
targetMap: `${r.byte(p + 3)}:${r.byte(p + 4)}`,
});
}
const coordinateCount = r.byte(p++);
for (let i = 0; i < coordinateCount; i++, p += 8) {
map.coordinateEvents.push({
scene: r.byte(p),
y: r.byte(p + 1),
x: r.byte(p + 2),
script: r.near(p + 4, scriptBank),
});
}
const backgroundCount = r.byte(p++);
for (let i = 0; i < backgroundCount; i++, p += 5) {
map.backgroundEvents.push({
y: r.byte(p),
x: r.byte(p + 1),
kind: r.byte(p + 2),
script: r.near(p + 3, scriptBank),
});
}
const objectCount = r.byte(p++);
for (let i = 0; i < objectCount; i++, p += 13) {
map.objects.push({
id: i + 2,
sprite: r.byte(p),
y: r.byte(p + 1) - 4,
x: r.byte(p + 2) - 4,
movement: r.byte(p + 3),
hour1: r.byte(p + 5),
hour2: r.byte(p + 6),
palette: r.byte(p + 7) >> 4,
kind: r.byte(p + 7) & 15,
script: r.near(p + 9, scriptBank),
eventFlag: r.word(p + 11),
});
}
p = r.near(attributes + 7, scriptBank);
const sceneCount = r.byte(p++);
for (let i = 0; i < sceneCount; i++, p += 4)
map.sceneScripts.push(r.near(p, scriptBank));
const callbackCount = r.byte(p++);
for (let i = 0; i < callbackCount; i++, p += 3)
map.callbacks.push({
kind: r.byte(p),
script: r.near(p + 1, scriptBank),
});
p = attributes + 12;
for (const [direction, bit] of [
["up", 8],
["down", 4],
["left", 2],
["right", 1],
] as const) {
if (!(r.byte(attributes + 11) & bit)) continue;
const signed = (v: number) => (v > 127 ? v - 256 : v);
map.connections.push({
direction,
targetMap: `${r.byte(p)}:${r.byte(p + 1)}`,
y: signed(r.byte(p + 8)),
x: signed(r.byte(p + 9)),
});
p += 12;
}
this.maps.set(id, map);
return map;
}
tileset(map: GameMap): Tileset {
const key = `${map.tileset}:${map.group}`;
const cached = this.tilesets.get(key);
if (cached) return cached;
const r = this.reader,
base = SYMBOLS.tilesets + map.tileset * 15;
const graphics = decompress(r, r.far(base), 0x4000);
const meta = r.far(base + 3),
collision = r.far(base + 6);
// Each tileset contains 128 metatiles (4x4 8px tiles), each with four
// quadrant permissions for the 16px movement grid.
const result: Tileset = {
graphics,
metatiles: r.slice(meta, 128 * 16),
collision: r.slice(collision, 128 * 4),
paletteMap: r.slice(r.near(base + 13, r.bank(SYMBOLS.tilesets)), 128),
};
if ([1, 2, 4].includes(map.tileset)) {
const roof = r.byte(SYMBOLS.roofTypes + map.group);
if (roof !== 255)
result.graphics.set(
r.slice(SYMBOLS.roofs + roof * 9 * 16, 9 * 16),
0x0a * 16,
);
}
this.tilesets.set(key, result);
return result;
}
block(map: GameMap, x: number, y: number): number {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return map.border;
return map.blocks[Math.floor(y / 2) * (map.width / 2) + Math.floor(x / 2)];
}
collision(map: GameMap, x: number, y: number): number {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 7;
const block = this.block(map, x, y);
if (block >= 128) throw new Error("Map contains an invalid block.");
return this.tileset(map).collision[block * 4 + (y % 2) * 2 + (x % 2)];
}
permission(collision: number): number {
return this.reader.byte(SYMBOLS.collisions + collision) & 15;
}
palette(offset: number): Palette {
return Array.from({ length: 4 }, (_, i) => {
const color = this.reader.word(offset + i * 2);
return [
Math.round(((color & 31) * 255) / 31),
Math.round((((color >> 5) & 31) * 255) / 31),
Math.round((((color >> 10) & 31) * 255) / 31),
];
});
}
palettes(map: GameMap, time: number): Palette[] {
const r = this.reader;
const group =
r.near(
SYMBOLS.environmentColors + map.environment * 2,
r.bank(SYMBOLS.environmentColors),
) +
time * 8;
const palettes = Array.from({ length: 8 }, (_, i) =>
this.palette(
map.tileset === 5
? SYMBOLS.housePalettes + i * 8
: SYMBOLS.backgroundPalettes + r.byte(group + i) * 8,
),
);
if (map.environment === 1 || map.environment === 2) {
const roof = SYMBOLS.roofPalettes + map.group * 8 + (time >= 2 ? 4 : 0);
const colors = this.palette(roof);
palettes[6] = [palettes[6][0], colors[0], colors[1], palettes[6][3]];
}
return palettes;
}
objectPalette(index: number, time: number): Palette {
return this.palette(SYMBOLS.objectPalettes + time * 64 + (index & 7) * 8);
}
sprite(id: number): Sprite | undefined {
// Variable decorations and Pokémon icons need separate decoders.
if (id < 1 || id >= 0xa0) return undefined;
const cached = this.sprites.get(id);
if (cached) return cached;
const r = this.reader,
p = SYMBOLS.sprites + (id - 1) * 6;
const length = r.byte(p + 2),
walking = r.byte(p + 4) === 1;
const sprite = {
graphics: r.slice(r.near(p, r.byte(p + 3)), length * (walking ? 2 : 1)),
palette: r.byte(p + 5),
walking,
};
this.sprites.set(id, sprite);
return sprite;
}
facing(direction: Direction, frame: number): FacingTile[] {
const facing =
["down", "up", "left", "right"].indexOf(direction) * 4 + (frame % 4);
const r = this.reader,
pointer = r.near(SYMBOLS.facings + facing * 2, r.bank(SYMBOLS.facings));
return Array.from({ length: r.byte(pointer) }, (_, i) => {
const p = pointer + 1 + i * 4;
return {
y: r.byte(p),
x: r.byte(p + 1),
attributes: r.byte(p + 2),
tile: r.byte(p + 3),
};
});
}
}
+57
View File
@@ -0,0 +1,57 @@
import { RomReader } from "./reader";
/** Native translation of Crystal's LZ data format (home/decompress.asm).
* Copy commands may overlap their own output. Addresses are relative to the
* decompressed stream, never arbitrary memory. */
export function decompress(
reader: RomReader,
offset: number,
limit = 0x10000,
): Uint8Array {
const output: number[] = [];
let cursor = offset;
const read = () => reader.byte(cursor++);
for (let commandCount = 0; commandCount < limit; commandCount++) {
const header = read();
if (header === 0xff) return Uint8Array.from(output);
let command = header >> 5;
let length = (header & 31) + 1;
if (command === 7) {
command = (header >> 2) & 7;
length = (((header & 3) << 8) | read()) + 1;
}
if (command === 7 || output.length + length > limit)
throw new Error("Invalid or oversized compressed graphic.");
if (command === 0) {
for (let i = 0; i < length; i++) output.push(read());
} else if (command === 1) {
const value = read();
for (let i = 0; i < length; i++) output.push(value);
} else if (command === 2) {
const a = read(),
b = read();
for (let i = 0; i < length; i++) output.push(i % 2 ? b : a);
} else if (command === 3) {
for (let i = 0; i < length; i++) output.push(0);
} else {
const first = read();
let source =
first & 0x80
? output.length - (first & 0x7f) - 1
: (first << 8) | read();
for (let i = 0; i < length; i++) {
if (source < 0 || source >= output.length)
throw new Error("Invalid compressed graphic back-reference.");
let value = output[source];
if (command === 5) {
value = ((value & 0x55) << 1) | ((value >> 1) & 0x55);
value = ((value & 0x33) << 2) | ((value >> 2) & 0x33);
value = ((value & 0x0f) << 4) | (value >> 4);
}
output.push(value);
source += command === 6 ? -1 : 1;
}
}
}
throw new Error("Compressed graphic has no terminator.");
}
+30
View File
@@ -0,0 +1,30 @@
/** Addresses only, from PRET 7a7881d0d62e0ddbd82dcf10e7116807487ac651.
* RGBDS 1.0.3 crystal11 build was byte-identical to the user-supplied ROM.
* No original graphics, maps, dialogue, music or ROM instructions live here.
* See docs/NATIVE_PORT.md for source references and verification procedure. */
export const SYMBOLS = {
mapGroups: 0x94000,
tilesets: 0x4d596,
collisions: 0x4ce1f,
environmentColors: 0xb279,
backgroundPalettes: 0xb319,
objectPalettes: 0xb469,
roofPalettes: 0xb569,
roofTypes: 0x1c021,
roofs: 0x1c03c,
housePalettes: 0x495ee,
sprites: 0x14736,
facings: 0x4049,
standardScripts: 0xbc000,
} as const;
/** The currently ported area. Story callbacks are not executed yet. */
export const NATIVE_MAPS: Record<string, string> = {
"24:4": "New Bark Town",
"24:5": "Elm’s lab",
"24:6": "Your home",
"24:7": "Your room",
"24:8": "Neighbor’s house",
"24:9": "Elm’s house",
};
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
+54
View File
@@ -0,0 +1,54 @@
/** Bounds-checked access to the user's cartridge. No CPU or hardware state. */
export class RomReader {
constructor(readonly bytes: Uint8Array) {}
byte(offset: number): number {
this.check(offset, 1);
return this.bytes[offset];
}
word(offset: number): number {
return this.byte(offset) | (this.byte(offset + 1) << 8);
}
slice(offset: number, length: number): Uint8Array {
this.check(offset, length);
return this.bytes.slice(offset, offset + length);
}
address(bank: number, address: number): number {
if (
!Number.isInteger(bank) ||
bank < 0 ||
bank >= this.bytes.length / 0x4000 ||
!Number.isInteger(address) ||
address < 0 ||
address >= 0x8000
) {
throw new Error("Invalid ROM bank or address.");
}
const offset =
address < 0x4000 ? address : bank * 0x4000 + address - 0x4000;
this.check(offset, 1);
return offset;
}
near(pointer: number, bank: number): number {
return this.address(bank, this.word(pointer));
}
far(pointer: number): number {
return this.near(pointer + 1, this.byte(pointer));
}
bank(offset: number): number {
this.check(offset, 1);
return Math.floor(offset / 0x4000);
}
private check(offset: number, length: number) {
if (
!Number.isInteger(offset) ||
!Number.isInteger(length) ||
offset < 0 ||
length < 0 ||
offset + length > this.bytes.length
) {
throw new Error(
"Cartridge data is truncated or contains an invalid pointer.",
);
}
}
}
+152
View File
@@ -0,0 +1,152 @@
import type { GameRuntime } from "../app/runtime";
import type { Button } from "../input/types";
import { CrystalAssets } from "./rom/assets";
import { GameWorld } from "./world/world";
import type { Direction } from "./world/types";
import { WorldRenderer } from "./rendering/renderer";
export class NativeRuntime implements GameRuntime {
readonly id = "crystal-native-world-v1";
world?: GameWorld;
private renderer?: WorldRenderer;
private pressed = new Set<Button>();
private raf = 0;
private lastTime = 0;
private elapsed = 0;
private animation?: { x: number; y: number; elapsed: number };
private frame = 0;
constructor(
private onUpdate: (world: GameWorld) => void,
private onError: (error: unknown) => void,
private player: () => { name: string; avatar: "chris" | "kris" },
) {}
async load(bytes: Uint8Array, canvas: HTMLCanvasElement) {
this.dispose();
const assets = new CrystalAssets(bytes);
const world = new GameWorld(assets);
const player = this.player();
world.name = player.name;
world.avatar = player.avatar;
this.world = world;
this.renderer = new WorldRenderer(assets, canvas);
this.publish();
}
play() {
if (!this.world || this.raf) return;
this.lastTime = 0;
this.raf = requestAnimationFrame(this.tick);
}
pause() {
cancelAnimationFrame(this.raf);
this.raf = 0;
this.lastTime = 0;
this.pressed.clear();
this.elapsed = 0;
this.animation = undefined;
if (this.world && this.renderer) this.renderer.render(this.world);
}
setButton(button: Button, pressed: boolean) {
if (!pressed) {
this.pressed.delete(button);
return;
}
if (this.pressed.has(button) || !this.world) return;
this.pressed.add(button);
if (button === "a") {
this.animation = undefined;
this.world.interact();
this.publish();
}
if (button === "b" && this.world.dialogue) {
this.world.advanceDialogue();
this.publish();
}
if (button === "start" || button === "select") {
this.world.notice =
"Use Save session or Export backup below. Party, pack and Pokégear menus are not ported yet.";
this.onUpdate(this.world);
}
if (["up", "down", "left", "right"].includes(button) && !this.animation)
this.elapsed = 8 / 60;
}
private tick = (now: number) => {
try {
const world = this.world!;
const delta = this.lastTime
? Math.min((now - this.lastTime) / 1000, 0.05)
: 0;
this.lastTime = now;
this.elapsed += delta;
if (this.animation) {
this.animation.elapsed += delta;
if (this.animation.elapsed >= 8 / 60) this.animation = undefined;
}
const direction = [...this.pressed]
.reverse()
.find((button) => ["up", "down", "left", "right"].includes(button)) as
Direction | undefined;
if (
direction &&
!world.dialogue &&
this.elapsed >= 8 / 60 &&
!this.animation
) {
this.elapsed = 0;
const { x, y, mapId } = world;
if (world.move(direction) && world.mapId === mapId) {
this.animation = { x: x * 16, y: y * 16, elapsed: 0 };
this.frame = (this.frame + 1) % 4;
}
this.onUpdate(world);
}
const progress = this.animation ? this.animation.elapsed / (8 / 60) : 1;
const x = this.animation
? this.animation.x + (world.x * 16 - this.animation.x) * progress
: world.x * 16;
const y = this.animation
? this.animation.y + (world.y * 16 - this.animation.y) * progress
: world.y * 16;
this.renderer!.render(
world,
x,
y,
this.animation ? (this.frame % 2 ? 1 : 3) : 0,
);
this.raf = requestAnimationFrame(this.tick);
} catch (error) {
// Stop without rendering again: the renderer may be what failed.
cancelAnimationFrame(this.raf);
this.raf = 0;
this.lastTime = 0;
this.pressed.clear();
this.animation = undefined;
this.elapsed = 0;
this.onError(error);
}
};
private publish() {
if (this.world) {
this.renderer?.render(this.world);
this.onUpdate(this.world);
}
}
captureState() {
if (!this.world) throw new Error("Load your ROM first.");
return new TextEncoder().encode(JSON.stringify(this.world.save()));
}
restoreState(bytes: Uint8Array) {
if (!this.world || bytes.length > 64_000)
throw new Error("Invalid native save size.");
this.world.restore(
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
);
this.animation = undefined;
this.pressed.clear();
this.publish();
}
dispose() {
this.pause();
this.world = undefined;
this.renderer = undefined;
}
}
+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.");
}
+77
View File
@@ -0,0 +1,77 @@
export type Direction = "down" | "up" | "left" | "right";
export const VECTORS: Record<Direction, readonly [number, number]> = {
down: [0, 1],
up: [0, -1],
left: [-1, 0],
right: [1, 0],
};
export const DIRECTION_ORDER: Direction[] = ["down", "up", "left", "right"];
export interface Warp {
x: number;
y: number;
targetMap: string;
targetWarp: number;
}
export interface BackgroundEvent {
x: number;
y: number;
kind: number;
script: number;
}
export interface MapObject {
id: number;
sprite: number;
x: number;
y: number;
movement: number;
palette: number;
kind: number;
script: number;
eventFlag: number;
hour1: number;
hour2: number;
}
export interface MapConnection {
direction: Direction;
targetMap: string;
x: number;
y: number;
}
export interface GameMap {
id: string;
group: number;
number: number;
name: string;
width: number;
height: number;
border: number;
tileset: number;
environment: number;
paletteMode: number;
blocks: Uint8Array;
warps: Warp[];
backgroundEvents: BackgroundEvent[];
objects: MapObject[];
connections: MapConnection[];
sceneScripts: number[];
callbacks: { kind: number; script: number }[];
coordinateEvents: { scene: number; x: number; y: number; script: number }[];
}
export interface Tileset {
graphics: Uint8Array;
metatiles: Uint8Array;
collision: Uint8Array;
paletteMap: Uint8Array;
}
export type Palette = readonly (readonly [number, number, number])[];
export interface Sprite {
graphics: Uint8Array;
palette: number;
walking: boolean;
}
export interface FacingTile {
x: number;
y: number;
attributes: number;
tile: number;
}
+370
View File
@@ -0,0 +1,370 @@
import { CrystalAssets } from "../rom/assets";
import { INITIAL_POSITION, NATIVE_MAPS, SYMBOLS } from "../rom/profile";
import {
runScript,
type ScriptResult,
type ScriptState,
} from "../scripts/interpreter";
import {
DIRECTION_ORDER,
VECTORS,
type Direction,
type MapObject,
} from "./types";
export interface NativeSave {
format: "crystal-native";
version: 1;
map: string;
x: number;
y: number;
direction: Direction;
name: string;
avatar: "chris" | "kris";
tilesWalked: number;
events: number[];
flags: number[];
variables: Record<number, number>;
}
/** Deterministic native world rules. Rendering, browser APIs and emulator
* registers are deliberately absent; callers provide time and input. */
export class GameWorld {
mapId: string = INITIAL_POSITION.map;
x: number = INITIAL_POSITION.x;
y: number = INITIAL_POSITION.y;
direction: Direction = "down";
name = "CHRIS";
avatar: "chris" | "kris" = "chris";
tilesWalked = 0;
state: ScriptState = { events: new Set(), flags: new Set(), variables: {} };
dialogue:
{ pages: string[]; index: number; result: ScriptResult } | undefined;
notice = "";
diagnostic = "";
private objectDirections = new Map<number, Direction>();
constructor(
readonly assets: CrystalAssets,
private now: () => Date = () => new Date(),
) {
// Port the original initialization event table through the script engine;
// this supplies NPC visibility flags without distributing a copied table.
const initial = assets.reader.far(SYMBOLS.standardScripts + 24 * 3);
this.state = runScript(assets.reader, initial, this.state, this.name);
assets.map(this.mapId);
}
get map() {
return this.assets.map(this.mapId);
}
get timeOfDay(): number {
const mode = this.map.paletteMode;
if (mode === 1) return 1;
if (mode === 2) return 2;
if (mode === 3) return 0;
if (mode === 4) return 3;
const hour = this.now().getHours();
return hour >= 4 && hour < 10 ? 0 : hour >= 10 && hour < 18 ? 1 : 2;
}
objects(): MapObject[] {
const hour = this.now().getHours(),
timeMask = 1 << this.timeOfDay;
return this.map.objects.filter((object) => {
if (
object.eventFlag !== 0xffff &&
this.state.events.has(object.eventFlag)
)
return false;
if (!this.assets.sprite(object.sprite)) return false;
if (object.hour1 === 0xff)
return object.hour2 === 0xff || Boolean(object.hour2 & timeMask);
if (object.hour1 === object.hour2) return true;
return object.hour1 < object.hour2
? hour >= object.hour1 && hour < object.hour2
: hour >= object.hour1 || hour < object.hour2;
});
}
objectDirection(object: MapObject): Direction {
return (
this.objectDirections.get(object.id) ??
DIRECTION_ORDER[object.movement - 6] ??
"down"
);
}
private clearNotice() {
this.notice = "";
this.diagnostic = "";
}
private canStand(x: number, y: number): boolean {
if (x < 0 || y < 0 || x >= this.map.width || y >= this.map.height)
return false;
if (this.assets.permission(this.assets.collision(this.map, x, y)) !== 0)
return false;
return !this.objects().some((object) => object.x === x && object.y === y);
}
move(direction: Direction): boolean {
if (this.dialogue) return false;
this.clearNotice();
this.direction = direction;
const [dx, dy] = VECTORS[direction],
x = this.x + dx,
y = this.y + dy;
if (x < 0 || y < 0 || x >= this.map.width || y >= this.map.height) {
if (
this.map.connections.some(
(connection) => connection.direction === direction,
)
)
this.notice = "Routes beyond New Bark Town are not ported yet.";
return false;
}
const collision = this.assets.collision(this.map, x, y);
// Forced movement, surfing and ledges need their own translated rules.
// Refuse them rather than treating a land permission as ordinary walking.
if ([0x30, 0x40, 0x50, 0x60, 0xa0].includes(collision & 0xf0)) {
this.notice = "This movement type is not ported yet.";
return false;
}
if (!this.canStand(x, y)) return false;
// Directional edge walls: up/down/left/right encoded as hardware direction IDs.
const edges: Record<number, Direction[]> = {
0xb0: ["right"],
0xb1: ["left"],
0xb2: ["up"],
0xb3: ["down"],
0xb4: ["down", "right"],
0xb5: ["down", "left"],
0xb6: ["up", "right"],
0xb7: ["up", "left"],
};
const source = this.assets.collision(this.map, this.x, this.y);
const opposite: Record<Direction, Direction> = {
down: "up",
up: "down",
left: "right",
right: "left",
};
if (
edges[source]?.includes(direction) ||
edges[collision]?.includes(opposite[direction])
)
return false;
const warp = this.map.warps.find((warp) => warp.x === x && warp.y === y);
if (warp) {
if (
!Object.hasOwn(NATIVE_MAPS, warp.targetMap) ||
warp.targetWarp === 0 ||
warp.targetWarp === 255
) {
this.notice = "This destination is not ported yet.";
return false;
}
const target = this.assets.map(warp.targetMap),
arrival = target.warps[warp.targetWarp - 1];
if (!arrival) throw new Error("Warp destination is invalid.");
this.mapId = target.id;
this.x = arrival.x;
this.y = arrival.y;
this.objectDirections.clear();
this.direction = "down";
const arrivalCollision = this.assets.collision(target, this.x, this.y);
const exitY =
arrivalCollision === 0x70
? this.y - 1
: arrivalCollision === 0x71
? this.y + 1
: this.y;
if (exitY !== this.y && this.canStand(this.x, exitY)) {
this.direction = exitY < this.y ? "up" : "down";
this.y = exitY;
}
} else {
this.x = x;
this.y = y;
}
this.tilesWalked++;
return true;
}
interact() {
if (this.dialogue) {
this.advanceDialogue();
return;
}
this.clearNotice();
const [dx, dy] = VECTORS[this.direction];
let x = this.x + dx,
y = this.y + dy;
const collision = this.assets.collision(this.map, x, y);
if (collision === 0x90) {
x += dx;
y += dy;
}
const object = this.objects().find(
(object) => object.x === x && object.y === y,
);
let script: number | undefined;
if (object) {
if (object.kind !== 0) {
this.notice = "This interaction is not ported yet.";
return;
}
script = object.script;
} else {
const event = this.map.backgroundEvents.find(
(event) => event.x === x && event.y === y,
);
if (event) {
if (event.kind >= 5) {
this.notice = "This conditional interaction is not ported yet.";
return;
}
const requiredDirection = ["up", "down", "right", "left"][
event.kind - 1
];
if (event.kind && requiredDirection !== this.direction) return;
script = event.script;
} else {
const standard: Record<number, number> = {
0x91: 2,
0x97: 9,
0x9d: 8,
0x96: 6,
0x9f: 5,
};
if (standard[collision] !== undefined && this.direction === "up")
script = this.assets.reader.far(
SYMBOLS.standardScripts + standard[collision] * 3,
);
}
}
if (script === undefined) {
this.notice = "Nothing to interact with here.";
return;
}
try {
const result = runScript(
this.assets.reader,
script,
this.state,
this.name,
);
if (result.facePlayer && object) {
const opposite: Record<Direction, Direction> = {
down: "up",
up: "down",
left: "right",
right: "left",
};
this.objectDirections.set(object.id, opposite[this.direction]);
}
if (result.pages.length)
this.dialogue = { pages: result.pages, index: 0, result };
else this.commit(result);
} catch (error) {
this.notice =
"This interaction needs game logic that has not been ported yet. Your progress is unchanged.";
this.diagnostic = error instanceof Error ? error.message : String(error);
}
}
advanceDialogue() {
if (!this.dialogue) return;
if (++this.dialogue.index < this.dialogue.pages.length) return;
this.commit(this.dialogue.result);
this.dialogue = undefined;
}
private commit(result: ScriptResult) {
this.state = {
events: result.events,
flags: result.flags,
variables: result.variables,
};
for (const turn of result.turns) {
if (DIRECTION_ORDER[turn.direction])
this.objectDirections.set(turn.object, DIRECTION_ORDER[turn.direction]);
}
}
save(): NativeSave {
// Incomplete interactions are replayed after reload; no partial quest writes.
return {
format: "crystal-native",
version: 1,
map: this.mapId,
x: this.x,
y: this.y,
direction: this.direction,
name: this.name,
avatar: this.avatar,
tilesWalked: this.tilesWalked,
events: [...this.state.events].sort((a, b) => a - b),
flags: [...this.state.flags].sort((a, b) => a - b),
variables: { ...this.state.variables },
};
}
restore(value: unknown) {
const save = value as NativeSave;
const integer = (
value: unknown,
min: number,
max: number,
): value is number =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
const ids = (value: unknown): value is number[] =>
Array.isArray(value) &&
value.length <= 2048 &&
value.every((value) => integer(value, 0, 0xffff)) &&
new Set(value).size === value.length;
if (
!save ||
save.format !== "crystal-native" ||
save.version !== 1 ||
!Object.hasOwn(NATIVE_MAPS, save.map) ||
!integer(save.x, 0, 127) ||
!integer(save.y, 0, 127) ||
!DIRECTION_ORDER.includes(save.direction) ||
typeof save.name !== "string" ||
!/^[A-Za-z0-9 -]{1,7}$/.test(save.name) ||
!["chris", "kris"].includes(save.avatar) ||
!integer(save.tilesWalked, 0, Number.MAX_SAFE_INTEGER) ||
!ids(save.events) ||
!ids(save.flags) ||
!save.variables ||
typeof save.variables !== "object" ||
Array.isArray(save.variables) ||
Object.entries(save.variables).some(
([id, value]) =>
!/^\d+$/.test(id) ||
!integer(Number(id), 0, 255) ||
!integer(value, 0, 255),
)
) {
throw new Error(
"Invalid native save. Emulator snapshots cannot be imported into this port.",
);
}
const map = this.assets.map(save.map);
if (
save.x >= map.width ||
save.y >= map.height ||
this.assets.permission(this.assets.collision(map, save.x, save.y)) !== 0
)
throw new Error("The saved position is outside the walkable map.");
// Validate completely before changing live state.
this.mapId = save.map;
this.x = save.x;
this.y = save.y;
this.direction = save.direction;
this.name = save.name;
this.avatar = save.avatar;
this.tilesWalked = save.tilesWalked;
this.state = {
events: new Set(save.events),
flags: new Set(save.flags),
variables: { ...save.variables },
};
this.dialogue = undefined;
this.objectDirections.clear();
this.clearNotice();
}
}