Files
Crystal/src/game/rom/assets.ts
T
Sébastien QUEROLandClaude Opus 5.5 6a74f59e46
Checks / web (push) Canceled after 0s
Let NPCs roam as HandleNPCStep does
- Random walks and spins from SpriteMovementData, with random sleeps.
- CanObjectMoveInDirection: side walls, land, objects' current and
  previous tiles, the radius (stored plus one) and the screen edge.
- Objects freeze while text is open and during applymovement, and
  return home when both they and their home leave the object window.
- Wandering positions are not saved; test helpers replan around NPCs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 17:40:12 +02:00

438 lines
15 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,
RAM,
SYMBOLS,
} from "./profile";
import { defaultTextContext, readString } from "../scripts/text";
import { PokemonData } from "./pokemon";
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),
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),
];
});
}
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);
}
/** 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 for a map: encounter rates and seven slots per
* time of day (data/wild/johto_grass.asm). */
grassWildMons(
id: string,
): { rates: number[]; slots: { level: number; species: number }[][] } | undefined {
const r = this.reader;
for (let p = SYMBOLS.johtoGrassWildMons, 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.");
}
/** 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;
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), 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),
);
}
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[] {
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),
};
});
}
}