Checks / web (push) Canceled after 0s
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>
511 lines
18 KiB
TypeScript
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),
|
|
};
|
|
});
|
|
}
|
|
}
|