Files
Crystal/src/game/rom/pokedex.ts
T
samyandClaude Opus 5.5 cdee576818
Checks / web (push) Failing after 21s
Private ROM checks / private-rom (push) Failing after 14m27s
Port the Pokédex list, entries and tab (#35)
The start menu's POKéDEX runs Pokedex in New mode: the list up to the
last seen Pokémon, its cursor and page moves, and DisplayDexEntry with
the category, number, footprint, and the height, weight and pages once
caught. The Pokédex tab shows the same records from the loaded game.
Order, entries and footprints are compared with the pinned PRET source.

AREA, CRY, PRNT, the option and search screens, Old and A to Z modes
and Unown mode remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:19:24 +02:00

175 lines
6.3 KiB
TypeScript

import { RomReader } from "./reader";
import { decompress } from "./lz";
import { NUM_POKEMON, POKEDEX } from "./profile";
import { defaultTextContext, readString } from "../scripts/text";
import type { Palette } from "../world/types";
/** A Pokédex entry (data/pokemon/dex_entries): the species category,
* height and weight, and the two pages of text. */
export interface DexEntry {
/** The species name DisplayDexEntry prints under the name, as "SEED". */
category: string;
/** Feet and inches as stored: 204 is 2'04". Zero leaves "?'??". */
height: number;
/** Tenths of a pound. Zero leaves "???". */
weight: number;
/** Each page's lines, joined by "\n" as <NEXT> breaks them. */
pages: [string, string];
}
/** The Pokédex font's own tiles in the strings Pokedex_PlaceString draws
* (gfx/pokedex/pokedex.png): the foot and inch marks and the bottom bar's
* left edge. */
const DEX_TILES: Record<number, string> = {
0x3b: "",
0x5e: "'",
0x5f: '"',
};
/** The Pokédex's ROM data: its strings, the New mode order, the entries,
* footprints and the question mark picture. */
export class PokedexData {
private entries = new Map<number, DexEntry>();
private order?: number[];
constructor(readonly reader: RomReader) {}
private checkSpecies(species: number) {
if (!Number.isInteger(species) || species < 1 || species > NUM_POKEMON)
throw new Error("Unknown Pokémon species.");
}
/** A "@"-terminated string in the standard font. */
private string(address: number, limit = 32) {
return readString(this.reader, address, defaultTextContext(""), limit);
}
/** A string Pokedex_PlaceString draws, ended by -1, mixing the standard
* font with the Pokédex's tiles. */
dexString(label: keyof typeof POKEDEX): string {
const r = this.reader;
let output = "";
for (let p = POKEDEX[label], end = p + 32; p < end; p++) {
const code = r.byte(p);
if (code === 0xff) return output;
output +=
DEX_TILES[code] ??
readString(
new RomReader(new Uint8Array([code, 0x50])),
0,
defaultTextContext(""),
);
}
throw new Error("Unterminated Pokédex string.");
}
/** Pokedex_PlaceDefaultStringIfNotSeen.NameNotSeen. */
notSeenName(): string {
return this.string(
POKEDEX["Pokedex_PlaceDefaultStringIfNotSeen.NameNotSeen"],
);
}
/** NewPokedexOrder: the species in New mode's (Johto) order. */
newOrder(): readonly number[] {
if (!this.order) {
const order = [
...this.reader.slice(POKEDEX.NewPokedexOrder, NUM_POKEMON),
];
const sorted = [...order].sort((a, b) => a - b);
if (sorted.some((species, index) => species !== index + 1))
throw new Error("The New Pokédex order is not a permutation.");
this.order = order;
}
return this.order;
}
/** GetDexEntryPointer: the bank comes from the species' top two bits,
* one bank per 64 species. */
private entryAddress(species: number) {
const r = this.reader;
const index = species - 1;
const bank = r.byte(
POKEDEX["GetDexEntryPointer.PokedexEntryBanks"] + ((index >> 6) & 3),
);
return r.near(POKEDEX.PokedexDataPointerTable + index * 2, bank);
}
/** DisplayDexEntry and GetDexEntryPagePointer: the category, the height
* and weight words, then each page up to its "@". */
entry(species: number): DexEntry {
this.checkSpecies(species);
const cached = this.entries.get(species);
if (cached) return cached;
const r = this.reader;
let p = this.entryAddress(species);
const category = this.string(p, 16);
while (r.byte(p) !== 0x50) p++;
p++;
const height = r.word(p),
weight = r.word(p + 2);
p += 4;
const page1 = p;
while (r.byte(p) !== 0x50) p++;
const page2 = p + 1;
const entry: DexEntry = {
category,
height,
weight,
pages: [this.string(page1, 128), this.string(page2, 128)],
};
this.entries.set(species, entry);
return entry;
}
/** Pokedex_LoadAnyFootprint: two 1bpp tiles from the footprint sheet's
* row for the species, then the two below them, 16 tiles further. */
footprint(species: number): Uint8Array {
this.checkSpecies(species);
const index = species - 1;
const top = POKEDEX.Footprints + (index >> 3) * 0x100 + (index & 7) * 0x10;
const tiles = new Uint8Array(32);
tiles.set(this.reader.slice(top, 16), 0);
tiles.set(this.reader.slice(top + 0x80, 16), 16);
return tiles;
}
/** LoadQuestionMarkPic: the 7x7 picture of an unseen Pokémon, column by
* column like a front picture. */
questionMarkPic(): Uint8Array {
const tiles = decompress(
this.reader,
POKEDEX["LoadQuestionMarkPic.QuestionMarkLZ"],
0x2000,
);
if (tiles.length < 49 * 16) throw new Error("Picture data is truncated.");
return tiles.slice(0, 49 * 16);
}
/** PokedexQuestionMarkPalette: _CGB_Pokedex's picture palette while the
* list shows (wCurPartySpecies is -1). */
questionMarkPalette(): Palette {
const r = this.reader;
return [0, 1, 2, 3].map((i) => {
const value = r.word(POKEDEX.PokedexQuestionMarkPalette + i * 2);
return [
Math.round(((value & 31) * 255) / 31),
Math.round((((value >> 5) & 31) * 255) / 31),
Math.round((((value >> 10) & 31) * 255) / 31),
] as const;
});
}
}
/** DisplayDexEntry's height: PrintNum with two of four digits before the
* point, which becomes the foot mark, written over the ".Height" string. */
export function dexHeight(template: string, height: number): string {
if (!height) return template;
const feet = String(Math.floor(height / 100)).padStart(2, " ");
const inches = String(height % 100).padStart(2, "0");
// The number starts three tiles into the string (hlcoord 12, 7 after 9, 7).
return template.slice(0, 3) + `${feet}'${inches}` + template.slice(8);
}
/** DisplayDexEntry's weight: PrintNum with four of five digits before the
* point, written over the ".Weight" string's question marks. */
export function dexWeight(template: string, weight: number): string {
if (!weight) return template;
const pounds = String(Math.floor(weight / 10)).padStart(4, " ");
// The number starts two tiles into the string (hlcoord 11, 9 after 9, 9).
return template.slice(0, 2) + `${pounds}.${weight % 10}` + template.slice(8);
}