Files
Crystal/src/game/rom/assets.ts
T
Sébastien QUEROLandClaude Opus 5.5 451464f7c6
Checks / web (push) Canceled after 0s
Complete the overworld foundation
Port forced movement (turn delays, bumps, ice, currents, pits), dynamic
return warps, elevators, Surf, Cut, Strength and cycling rules, working
map blocks with changeblock/changemapblocks, and the remaining NPC
movement types and movement commands.

Add tileset animation from the ROM's command tables, grass sprite
priority and special map palettes; room decorations with ownership, the
bedroom PC and save migration; and Key Items access to the Bicycle.

Fourteen original-engine traces compare positions, facing, actors, event
flags and scripted poses. Tile animation matches original VRAM for four
tilesets, and LoadMapPals matches 192 palette cases. skyfall_top now
keeps the $60 sprite offset its step function leaves behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:47:29 +02:00

511 lines
18 KiB
TypeScript

import { RomReader } from "./reader";
import { decompress } from "./lz";
import {
LAST_MAP_GROUP_SIZE,
MAP_GROUP_COUNT,
NATIVE_MAPS,
NUM_ENGINE_FLAGS,
NUM_SPRITEMOVEDATA,
OVERWORLD,
RAM,
SYMBOLS,
} from "./profile";
import { defaultTextContext, readString } from "../scripts/text";
import { PokemonData } from "./pokemon";
import { OW } from "../world/constants";
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;
readonly pokemon: PokemonData;
private maps = new Map<string, GameMap>();
private tilesets = new Map<string, Tileset>();
private sprites = new Map<number, Sprite>();
private sceneOwners?: Map<string, string>;
constructor(bytes: Uint8Array) {
this.reader = new RomReader(bytes);
this.pokemon = new PokemonData(this.reader);
}
/** Areas whose rules and scripts are ported. Other maps can be decoded,
* for example to draw a connected border, but cannot be entered. */
isPorted(id: string): boolean {
return Object.hasOwn(NATIVE_MAPS, id);
}
/** Header offset for a map ID, bounded by the ROM's own group table. */
private header(id: string): number {
const match = /^(\d{1,2}):(\d{1,3})$/.exec(id);
const group = Number(match?.[1]),
number = Number(match?.[2]);
if (!match || group < 1 || group > MAP_GROUP_COUNT || number < 1)
throw new Error("Unknown map.");
const r = this.reader,
bank = r.bank(SYMBOLS.mapGroups);
const first = r.near(SYMBOLS.mapGroups + (group - 1) * 2, bank);
const count =
group === MAP_GROUP_COUNT
? LAST_MAP_GROUP_SIZE
: (r.near(SYMBOLS.mapGroups + group * 2, bank) - first) / 9;
if (number > count) throw new Error("Unknown map.");
return first + (number - 1) * 9;
}
/** The location name the game shows for a landmark, from the ROM. */
landmark(index: number): string {
const r = this.reader,
entry = SYMBOLS.landmarks + index * 4;
const name = readString(
r,
r.near(entry + 2, r.bank(SYMBOLS.landmarks)),
defaultTextContext(""),
);
return name
.toLowerCase()
.replace(
/(^|[\s.-])(\p{L})/gu,
(_, gap, letter) => gap + letter.toUpperCase(),
);
}
/** The map that owns a map's scene variable (data/maps/scenes.asm). */
sceneOwner(id: string): string | undefined {
if (!this.sceneOwners) {
const owners = new Map<string, string>(),
byAddress = new Map<number, string>();
const r = this.reader;
for (let p = SYMBOLS.mapScenes, i = 0; i < 256; i++, p += 4) {
const group = r.byte(p);
if (group === 0xff) break;
const map = `${group}:${r.byte(p + 1)}`,
address = r.word(p + 2);
const owner = byAddress.get(address) ?? map;
byAddress.set(address, owner);
owners.set(map, owner);
}
this.sceneOwners = owners;
}
return this.sceneOwners.get(id);
}
map(id: string): GameMap {
const cached = this.maps.get(id);
if (cached) return cached;
const header = this.header(id);
const [group, number] = id.split(":").map(Number);
const r = this.reader;
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 location = this.landmark(r.byte(header + 5));
const map: GameMap = {
id,
group,
number,
name: NATIVE_MAPS[id] ?? location,
location,
landmark: r.byte(header + 5),
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),
radius: r.byte(p + 4),
sightRange: r.byte(p + 8),
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)];
}
/** Where a step coordinate outside `map` lands in a connected map. */
connected(
map: GameMap,
x: number,
y: number,
): { map: GameMap; x: number; y: number } | undefined {
const direction =
y < 0
? "up"
: y >= map.height
? "down"
: x < 0
? "left"
: x >= map.width
? "right"
: undefined;
// Corners are outside every connection strip and show border blocks.
if (!direction || ((x < 0 || x >= map.width) && (y < 0 || y >= map.height)))
return undefined;
const connection = map.connections.find((c) => c.direction === direction);
if (!connection) return undefined;
const target = this.map(connection.targetMap);
// Offsets are the coordinates EnterMapConnection assigns on crossing.
const position =
direction === "up"
? { x: x + connection.x, y: y + 1 + connection.y }
: direction === "down"
? { x: x + connection.x, y: y - map.height + connection.y }
: direction === "left"
? { x: x + 1 + connection.x, y: y + connection.y }
: { x: x - map.width + connection.x, y: y + connection.y };
if (
position.x < 0 ||
position.y < 0 ||
position.x >= target.width ||
position.y >= target.height
)
return undefined;
return { map: target, ...position };
}
/** A visible block, drawing connected maps beyond the edges as the
* original connection strips do, with the current map's tileset. */
visibleBlock(map: GameMap, x: number, y: number): number {
if (x >= 0 && y >= 0 && x < map.width && y < map.height)
return this.block(map, x, y);
const target = this.connected(map, x, y);
return target ? this.block(target.map, target.x, target.y) : map.border;
}
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),
];
});
}
/** LoadSpecialMapPalette: tilesets with their own eight palettes, which
* do not follow the time of day. */
private specialPalettes(map: GameMap): Palette[] | undefined {
const eight = (address: number) =>
Array.from({ length: 8 }, (_, i) => this.palette(address + i * 8));
switch (map.tileset) {
case OW.TILESET_POKECOM_CENTER:
return eight(OVERWORLD.PokeComPalette);
case OW.TILESET_BATTLE_TOWER_INSIDE:
return eight(OVERWORLD.BattleTowerInsidePalette);
case OW.TILESET_ICE_PATH:
// The Hall of Fame shares the tileset but is INDOOR.
return (map.environment & 7) === OW.INDOOR ? undefined : eight(OVERWORLD.IcePathPalette);
case OW.TILESET_HOUSE:
return eight(SYMBOLS.housePalettes);
case OW.TILESET_RADIO_TOWER:
return eight(OVERWORLD.RadioTowerPalette);
case OW.TILESET_MANSION: {
// LoadMansionPalette: MansionPalette1, then yellow from
// MansionPalette2, water from palette 6 and roof from palette 8.
const palettes = eight(OVERWORLD.MansionPalette1);
palettes[4] = this.palette(OVERWORLD.MansionPalette2);
palettes[3] = this.palette(OVERWORLD.MansionPalette1 + 6 * 8);
palettes[6] = this.palette(OVERWORLD.MansionPalette1 + 8 * 8);
return palettes;
}
}
return undefined;
}
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 =
this.specialPalettes(map) ??
Array.from({ length: 8 }, (_, i) =>
this.palette(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);
}
/** Collisions that allow wild encounters (CheckGrassCollision). */
grassCollisions(): number[] {
const r = this.reader,
values: number[] = [];
for (let p = SYMBOLS.grassCollisions; values.length < 32; p++) {
const value = r.byte(p);
if (value === 0xff) return values;
values.push(value);
}
throw new Error("Grass collision list is unterminated.");
}
/** JohtoGrassWildMons or KantoGrassWildMons for a map: encounter rates
* and seven slots per time of day (data/wild/johto_grass.asm). */
grassWildMons(
id: string,
kanto = false,
): { rates: number[]; slots: { level: number; species: number }[][] } | undefined {
const r = this.reader;
const table = kanto ? OVERWORLD.KantoGrassWildMons : SYMBOLS.johtoGrassWildMons;
for (let p = table, i = 0; i < 256; i++, p += 47) {
const group = r.byte(p);
if (group === 0xff) return undefined;
if (`${group}:${r.byte(p + 1)}` !== id) continue;
const rates = [r.byte(p + 2), r.byte(p + 3), r.byte(p + 4)];
const slots = [0, 1, 2].map((time) =>
Array.from({ length: 7 }, (_, slot) => {
const entry = p + 5 + time * 14 + slot * 2;
return { level: r.byte(entry), species: r.byte(entry + 1) };
}),
);
return { rates, slots };
}
throw new Error("Wild encounter data is unterminated.");
}
/** JohtoWaterWildMons or KantoWaterWildMons: one rate and three slots
* (data/wild/johto_water.asm). */
waterWildMons(
id: string,
kanto = false,
): { rate: number; slots: { level: number; species: number }[] } | undefined {
const r = this.reader;
const table = kanto ? OVERWORLD.KantoWaterWildMons : OVERWORLD.JohtoWaterWildMons;
for (let p = table, i = 0; i < 256; i++, p += 9) {
const group = r.byte(p);
if (group === 0xff) return undefined;
if (`${group}:${r.byte(p + 1)}` !== id) continue;
return {
rate: r.byte(p + 2),
slots: Array.from({ length: 3 }, (_, slot) => ({
level: r.byte(p + 3 + slot * 2),
species: r.byte(p + 4 + slot * 2),
})),
};
}
throw new Error("Wild encounter data is unterminated.");
}
/** Engine flags kept in the four bytes CheckDailyResetTimer clears
* (wDailyFlags1 through wUnusedDailyFlag), from the ROM's EngineFlags. */
dailyEngineFlags(): number[] {
const r = this.reader,
flags: number[] = [];
for (let index = 0; index < NUM_ENGINE_FLAGS; index++) {
const address = r.word(SYMBOLS.engineFlags + index * 3);
if (address >= RAM.dailyFlags1 && address <= RAM.unusedDailyFlag)
flags.push(index);
}
return flags;
}
/** SpriteMovementData for a SPRITEMOVEDATA_* type: the movement
* function, flags1 (FIXED_FACING, NOCLIP_*, ...) and palette flags. */
spriteMovement(type: number): {
fn: number;
/** OBJECT_ACTION_* the object starts with. */
action: number;
flags: number;
paletteFlags: number;
} {
if (type >= NUM_SPRITEMOVEDATA) throw new Error("Unknown movement type.");
const r = this.reader,
p = SYMBOLS.spriteMovementData + type * 6;
return {
fn: r.byte(p),
action: r.byte(p + 2),
flags: r.byte(p + 3),
paletteFlags: r.byte(p + 5),
};
}
/** Emote bubble graphics (data/sprites/emotes.asm). */
emote(index: number): Uint8Array {
const r = this.reader,
entry = SYMBOLS.emotes + index * 6;
if (index < 0 || index > 11) throw new Error("Unknown emote.");
return r.slice(
r.address(r.byte(entry + 3), r.word(entry)),
r.byte(entry + 2),
);
}
/** GetSprite: overworld sprites, and Pokémon drawn from their party
* menu icons (GetMonSprite). Variable sprites are resolved by the world
* from wVariableSprites first; day-care Pokémon are not ported. */
sprite(id: number): Sprite | undefined {
const cached = this.sprites.get(id);
if (cached) return cached;
const r = this.reader;
if (id >= OW.SPRITE_POKEMON && id < OW.SPRITE_POKEMON + OW.NUM_POKEMON_SPRITES) {
// LoadOverworldMonIcon: eight tiles, two frames in the down and up
// standing slots, with palette 0.
const species = r.byte(OVERWORLD.SpriteMons + id - OW.SPRITE_POKEMON);
const icon = r.byte(OVERWORLD.MonMenuIcons + species - 1);
const pointer = r.near(OVERWORLD.IconPointers + icon * 2, r.bank(OVERWORLD.IconPointers));
const sprite = { graphics: r.slice(pointer, 8 * 16), palette: 0, walking: true };
this.sprites.set(id, sprite);
return sprite;
}
if (id < 1 || id > OW.NUM_OVERWORLD_SPRITES) return undefined;
const 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[] {
return this.facingTiles(
["down", "up", "left", "right"].indexOf(direction) * 4 + (frame % 4),
);
}
/** FACING_* layouts; 0x14 is the emote bubble. */
facingTiles(facing: number): FacingTile[] {
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),
};
});
}
}