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(); private tilesets = new Map(); private sprites = new Map(); private spawnFlags?: Map; private sceneOwners?: Map; 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(), byAddress = new Map(); 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, phoneService: r.byte(header + 7) >> 4, attributes, 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; } /** The engine flag (ENGINE_FLYPOINT_*) of a wVisitedSpawns bit, from the * ROM's EngineFlags; undefined for spawn points without one. */ spawnEngineFlag(spawn: number): number | undefined { if (!this.spawnFlags) { const r = this.reader; this.spawnFlags = new Map(); for (let index = 0; index < NUM_ENGINE_FLAGS; index++) { const entry = SYMBOLS.engineFlags + index * 3, offset = r.word(entry) - RAM.visitedSpawns, mask = r.byte(entry + 2); if (offset >= 0 && offset < 4) this.spawnFlags.set(offset * 8 + Math.log2(mask), index); } } return this.spawnFlags.get(spawn); } /** SpawnPoints: a SPAWN_* point's map and position. */ spawnPoint(spawn: number): { map: string; x: number; y: number } { const r = this.reader, entry = SYMBOLS.spawnPoints + spawn * 4; return { map: `${r.byte(entry)}:${r.byte(entry + 1)}`, x: r.byte(entry + 2), y: r.byte(entry + 3), }; } /** IsSpawnPoint: the SPAWN_* index of a map, or -1. */ spawnIndex(map: string | null): number { const r = this.reader; if (map) for ( let entry = SYMBOLS.spawnPoints, index = 0; r.byte(entry) !== 0xff; entry += 4, index++ ) if (`${r.byte(entry)}:${r.byte(entry + 1)}` === map) return index; return -1; } /** 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), ); } /** HeadbuttTreeGFX: the shaking tree's two 2x2 frames, eight tiles. */ headbuttTree(): Uint8Array { return this.reader.slice(OVERWORLD.HeadbuttTreeGFX, 8 * 16); } /** GetIcon: a species' two-frame party menu icon (ReadMonMenuIcon and * IconPointers), eight tiles. */ monIcon(species: number): Uint8Array { const r = this.reader; const icon = r.byte(OVERWORLD.MonMenuIcons + species - 1); const pointer = r.near( OVERWORLD.IconPointers + icon * 2, r.bank(OVERWORLD.IconPointers), ); return r.slice(pointer, 8 * 16); } /** 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 sprite = { graphics: this.monIcon(species), 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), }; }); } }