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

+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),
};
});
}
}