PRET builds none of these releases, so scripts/map-revision.mjs derives where each keeps the profile's labels from the 1.1 build: a bank-by-bank alignment, then the exact references RGBDS recorded while linking 1.1, read at the matching place in the release, each result checked against its bytes. All but 1-3 labels per release are found; the rest, in code the release changed, are -1 so reading them refuses. check:reference verifies the generated file when the ROMs are present. CrystalAssets now points the profile at the cartridge's addresses, and the releases' characters (from their own fonts) and two European text codes decode. The introduction and Mom's scene play in each language and every map sweeps as in English, but the releases stay refused until the logic they localize (units, word order, layouts) is ported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
import { PokemonData } from "../rom/pokemon";
|
||
import { NAME_LETTERS } from "../rom/charset";
|
||
import type { Mail } from "./mail";
|
||
|
||
/** A party Pokémon with the fields of the original party_struct. */
|
||
export interface PartyMon {
|
||
species: number;
|
||
item: number;
|
||
/** Move IDs; 0 marks an empty slot. */
|
||
moves: [number, number, number, number];
|
||
otId: number;
|
||
ot: string;
|
||
nickname: string;
|
||
exp: number;
|
||
/** HP, Attack, Defense, Speed, Special stat experience. */
|
||
statExp: [number, number, number, number, number];
|
||
/** Attack, Defense, Speed, Special DVs as four nibbles. */
|
||
dvs: number;
|
||
pp: [number, number, number, number];
|
||
happiness: number;
|
||
pokerus: number;
|
||
/** Time of day and level, then gender and landmark (SetCaughtData). */
|
||
caughtData: number;
|
||
level: number;
|
||
status: number;
|
||
hp: number;
|
||
/** Max HP, Attack, Defense, Speed, Sp. Atk, Sp. Def. */
|
||
stats: [number, number, number, number, number, number];
|
||
/** sPartyMail's entry while the Pokémon holds MAIL. */
|
||
mail?: Mail;
|
||
/** An Egg: wPartySpecies holds EGG for this slot. `species` is what
|
||
* hatches, `happiness` counts the remaining egg cycles and the nickname
|
||
* is "EGG". */
|
||
egg?: true;
|
||
}
|
||
|
||
export const PARTY_LENGTH = 6;
|
||
/** wUnownDex and wFirstUnownSeen, kept with the game. */
|
||
export interface UnownRecord {
|
||
unownDex: number[];
|
||
firstUnownSeen: number;
|
||
}
|
||
/** UpdateUnownDex: an Unown's letter (GetUnownLetter) is added to the
|
||
* Unown Dex the first time it joins the party or a box. TryAddMonToParty
|
||
* also records the first letter seen, when none was. */
|
||
export function updateUnownDex(
|
||
record: UnownRecord,
|
||
mon: { species: number; dvs: number },
|
||
party = true,
|
||
) {
|
||
if (mon.species !== UNOWN_SPECIES) return;
|
||
const letter = PokemonData.unownLetter(mon.dvs);
|
||
if (!record.unownDex.includes(letter) && record.unownDex.length < 26)
|
||
record.unownDex.push(letter);
|
||
if (party && !record.firstUnownSeen) record.firstUnownSeen = letter;
|
||
}
|
||
const UNOWN_SPECIES = 201;
|
||
|
||
const STATUS_NAMES: [number, string][] = [
|
||
[0x08, " PSN"],
|
||
[0x10, " BRN"],
|
||
[0x20, " FRZ"],
|
||
[0x40, " PAR"],
|
||
];
|
||
function statusName(status: number) {
|
||
if (status & 7) return " SLP";
|
||
return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? "";
|
||
}
|
||
/** A party menu row's level, HP and status (or FNT); an Egg shows none. */
|
||
export function partyMonDetails(mon: PartyMon): string {
|
||
if (mon.egg) return "";
|
||
return `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${mon.hp ? statusName(mon.status) : " FNT"}`;
|
||
}
|
||
/** The naming screen's <PK> and <MN> glyphs, each one character of a name,
|
||
* written as two small capitals. */
|
||
export const PK = "ᴾᴷ",
|
||
MN = "ᴹᴺ";
|
||
/** One character of a name: the naming screen's letters and symbols, plus
|
||
* the digits and apostrophes of default species names and the European
|
||
* releases' letters. */
|
||
const NAME_CHARACTER = `(?:[A-Za-z0-9 ()":;[\\]?!♂♀/.,×'é${NAME_LETTERS}-]|${PK}|${MN})`;
|
||
/** Characters a nickname can hold (up to ten). */
|
||
export const NICKNAME_PATTERN = new RegExp(`^${NAME_CHARACTER}{1,10}$`, "u");
|
||
/** The same characters, for box names (up to eight). */
|
||
export const BOX_NAME_PATTERN = new RegExp(`^${NAME_CHARACTER}{1,8}$`, "u");
|
||
/** The same characters, for the player's and other trainers' names (up to
|
||
* seven, PLAYER_NAME_LENGTH less the terminator). */
|
||
export const TRAINER_NAME_PATTERN = new RegExp(`^${NAME_CHARACTER}{1,7}$`, "u");
|
||
/** BASE_HAPPINESS */
|
||
const BASE_HAPPINESS = 70;
|
||
const MAX_STAT_VALUE = 999;
|
||
const MAX_LEVEL = 100;
|
||
|
||
/** GetSquareRoot: the first of 1..255 whose square is not below `value`. */
|
||
function squareRoot(value: number) {
|
||
for (let root = 1; root < 255; root++) if (root * root >= value) return root;
|
||
return 255;
|
||
}
|
||
|
||
/** DVs per stat; the HP DV is built from the low bits of the others. */
|
||
export function statDv(dvs: number, stat: number): number {
|
||
const attack = (dvs >> 12) & 15,
|
||
defense = (dvs >> 8) & 15,
|
||
speed = (dvs >> 4) & 15,
|
||
special = dvs & 15;
|
||
if (stat === 0)
|
||
return (
|
||
((attack & 1) << 3) |
|
||
((defense & 1) << 2) |
|
||
((speed & 1) << 1) |
|
||
(special & 1)
|
||
);
|
||
return [attack, defense, speed, special, special][stat - 1];
|
||
}
|
||
|
||
/** CalcMonStatC for stat 0-5 (HP, Atk, Def, Spd, SpAtk, SpDef). */
|
||
export function calcStat(
|
||
base: number,
|
||
dvs: number,
|
||
stat: number,
|
||
level: number,
|
||
statExp: readonly number[] | undefined,
|
||
): number {
|
||
// Special attack and defense share the Special stat experience.
|
||
const experience = statExp ? statExp[Math.min(stat, 4)] : 0;
|
||
const bonus = statExp ? Math.floor(squareRoot(experience) / 4) : 0;
|
||
const value = Math.floor(
|
||
(((base + statDv(dvs, stat)) * 2 + bonus) * level) / 100,
|
||
);
|
||
return Math.min(MAX_STAT_VALUE, value + (stat === 0 ? level + 10 : 5));
|
||
}
|
||
|
||
export function calcStats(
|
||
data: PokemonData,
|
||
species: number,
|
||
dvs: number,
|
||
level: number,
|
||
statExp?: readonly number[],
|
||
): PartyMon["stats"] {
|
||
const base = data.baseData(species).stats;
|
||
return base.map((value, stat) =>
|
||
calcStat(value, dvs, stat, level, statExp),
|
||
) as PartyMon["stats"];
|
||
}
|
||
|
||
/** FillMoves for a new Pokémon: learn every move up to its level, skipping
|
||
* repeats and shifting out the oldest move when all slots are full. */
|
||
export function initialMoves(
|
||
data: PokemonData,
|
||
species: number,
|
||
level: number,
|
||
): PartyMon["moves"] {
|
||
const moves: number[] = [];
|
||
for (const learned of data.learnset(species)) {
|
||
if (learned.level > level) break;
|
||
if (moves.includes(learned.move)) continue;
|
||
if (moves.length === 4) moves.shift();
|
||
moves.push(learned.move);
|
||
}
|
||
while (moves.length < 4) moves.push(0);
|
||
return moves as PartyMon["moves"];
|
||
}
|
||
|
||
/** SetCaughtData (SetBoxmonOrEggmonCaughtData): time of day and level,
|
||
* then the player's gender bit and the map's landmark. */
|
||
export function caughtData(
|
||
timeOfDay: number,
|
||
level: number,
|
||
gender: number,
|
||
landmark: number,
|
||
): number {
|
||
return (
|
||
(((((timeOfDay + 1) & 3) << 6) | level) & 0xff) << 8 |
|
||
(gender << 7) |
|
||
landmark
|
||
);
|
||
}
|
||
|
||
/** GeneratePartyMonStats for a Pokémon given by a script (not a wild or
|
||
* trainer battle): random DVs, level-up moves, full PP and HP. */
|
||
export function generatePartyMon(
|
||
data: PokemonData,
|
||
options: {
|
||
species: number;
|
||
level: number;
|
||
item: number;
|
||
otId: number;
|
||
ot: string;
|
||
dvs: number;
|
||
caughtData: number;
|
||
},
|
||
): PartyMon {
|
||
const { species, level } = options;
|
||
if (!Number.isInteger(level) || level < 1 || level > 100)
|
||
throw new Error("Invalid level.");
|
||
const moves = initialMoves(data, species, level);
|
||
const stats = calcStats(data, species, options.dvs, level);
|
||
return {
|
||
species,
|
||
item: options.item,
|
||
moves,
|
||
otId: options.otId,
|
||
ot: options.ot,
|
||
nickname: data.speciesName(species),
|
||
exp: data.expAtLevel(data.baseData(species).growthRate, level),
|
||
statExp: [0, 0, 0, 0, 0],
|
||
dvs: options.dvs,
|
||
pp: moves.map((move) => (move ? data.move(move).pp : 0)) as PartyMon["pp"],
|
||
happiness: BASE_HAPPINESS,
|
||
pokerus: 0,
|
||
caughtData: options.caughtData,
|
||
level,
|
||
status: 0,
|
||
hp: stats[0],
|
||
stats,
|
||
};
|
||
}
|
||
|
||
/** CalcLevel: the highest level whose experience the total reaches. */
|
||
export function levelForExp(
|
||
data: PokemonData,
|
||
species: number,
|
||
exp: number,
|
||
): number {
|
||
const growth = data.baseData(species).growthRate;
|
||
let level = 1;
|
||
while (level < MAX_LEVEL && data.expAtLevel(growth, level + 1) <= exp)
|
||
level++;
|
||
return level;
|
||
}
|
||
|
||
/** ComputeMaxPP for a move with `ups` PP Ups: each adds a fifth of the base
|
||
* PP, clamped to 7, so a 40 PP move maxes at 61 instead of overflowing the
|
||
* packed byte at 64. The PP Ups stay in the top two bits. */
|
||
export function maxPP(data: PokemonData, move: number, ups: number): number {
|
||
const base = data.move(move).pp;
|
||
return (ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups);
|
||
}
|
||
|
||
/** The box_struct fields of a stored Pokémon: everything but status, HP
|
||
* and stats. */
|
||
export function validBoxFields(value: unknown): boolean {
|
||
const mon = value as PartyMon;
|
||
const integer = (value: unknown, min: number, max: number): value is number =>
|
||
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
|
||
const tuple = (value: unknown, length: number, max: number) =>
|
||
Array.isArray(value) &&
|
||
value.length === length &&
|
||
value.every((item) => integer(item, 0, max));
|
||
try {
|
||
return (
|
||
Boolean(mon) &&
|
||
typeof mon === "object" &&
|
||
integer(mon.species, 1, 251) &&
|
||
integer(mon.item, 0, 255) &&
|
||
tuple(mon.moves, 4, 251) &&
|
||
integer(mon.otId, 0, 0xffff) &&
|
||
typeof mon.ot === "string" &&
|
||
TRAINER_NAME_PATTERN.test(mon.ot) &&
|
||
typeof mon.nickname === "string" &&
|
||
NICKNAME_PATTERN.test(mon.nickname) &&
|
||
integer(mon.exp, 0, 0xffffff) &&
|
||
tuple(mon.statExp, 5, 0xffff) &&
|
||
integer(mon.dvs, 0, 0xffff) &&
|
||
tuple(mon.pp, 4, 255) &&
|
||
integer(mon.happiness, 0, 255) &&
|
||
integer(mon.pokerus, 0, 255) &&
|
||
integer(mon.caughtData, 0, 0xffff) &&
|
||
integer(mon.level, 1, 100) &&
|
||
(mon.egg === undefined || mon.egg === true)
|
||
);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Validate a stored Pokémon against the ROM's rules before restoring it. */
|
||
export function validPartyMon(
|
||
data: PokemonData,
|
||
value: unknown,
|
||
): value is PartyMon {
|
||
const mon = value as PartyMon;
|
||
const integer = (value: unknown, min: number, max: number): value is number =>
|
||
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
|
||
try {
|
||
if (
|
||
!validBoxFields(mon) ||
|
||
!integer(mon.status, 0, 255) ||
|
||
!Array.isArray(mon.stats) ||
|
||
mon.stats.length !== 6 ||
|
||
!mon.stats.every((stat) => integer(stat, 0, MAX_STAT_VALUE)) ||
|
||
!integer(mon.hp, 0, mon.stats[0])
|
||
)
|
||
return false;
|
||
// GiveExperiencePoints awards stat experience after every victory, but
|
||
// CalcMonStats only runs on a level-up (or a later PC recalculation).
|
||
// Preserve legitimate pending gains instead of silently applying them on
|
||
// load. Stored stats must stay inside the ROM-derived possible range.
|
||
const minimum = calcStats(data, mon.species, mon.dvs, mon.level);
|
||
const maximum = calcStats(data, mon.species, mon.dvs, mon.level, mon.statExp);
|
||
return mon.stats.every((stat, index) =>
|
||
stat >= minimum[index] && stat <= maximum[index]);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|