- Decode evolution lists from the ROM; all 251 match the pinned PRET source. - Compare CalcMonStats and CalcExpAtLevel against the original routines in the reference emulator. - Add a continuous opening playthrough from the intro to the catching tutorial, reloading a JSON save after every stage. - Port player switching in battle: the PKMN menu, forced choices after a faint, Shift mode's offer and double faints, with the original texts. - Port HP and status healing items in battle through the ROM's ItemEffects, HealingHPAmounts and StatusHealingActions tables. - Add the battle party menu and the Items pocket to the UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
413 lines
16 KiB
TypeScript
413 lines
16 KiB
TypeScript
import { test, expect, type Page } from "@playwright/test";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { CrystalAssets } from "../src/game/rom/assets";
|
|
import { BattleData } from "../src/game/battle/data";
|
|
import { applyTypeMatchups, calculateDamage } from "../src/game/battle/battle";
|
|
import { C } from "../src/game/battle/constants";
|
|
import { NATIVE_MAPS } from "../src/game/rom/profile";
|
|
import { calcStat } from "../src/game/party/pokemon";
|
|
import { resolve } from "node:path";
|
|
|
|
const romPath = resolve(
|
|
process.env.CRYSTAL_ROM_PATH ??
|
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
|
|
);
|
|
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
|
|
|
|
/** Banked addresses from the private PRET rebuild's symbol file. */
|
|
function loadSymbols() {
|
|
const symbols = new Map(
|
|
readFileSync(symbolsPath, "utf8")
|
|
.split("\n")
|
|
.flatMap((line) => {
|
|
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
|
|
return match
|
|
? [[match[3], { bank: parseInt(match[1], 16), address: parseInt(match[2], 16) }] as const]
|
|
: [];
|
|
}),
|
|
);
|
|
return {
|
|
at: (name: string) => symbols.get(name)!.address,
|
|
routine: (name: string) => symbols.get(name)!,
|
|
};
|
|
}
|
|
|
|
/** Open the reference page and hand it the ROM through an isolated file
|
|
* input, without an HTTP ROM endpoint. */
|
|
async function openReference(page: Page) {
|
|
await page.goto("/reference.html");
|
|
await page.evaluate(() => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.id = "reference-fixture";
|
|
document.body.append(input);
|
|
});
|
|
await page.locator("#reference-fixture").setInputFiles(romPath);
|
|
}
|
|
|
|
test("native decompression matches original GB assembly executing in the reference emulator", async ({
|
|
page,
|
|
}) => {
|
|
test.skip(!existsSync(romPath), "Requires the local user ROM.");
|
|
await openReference(page);
|
|
const result = await page.evaluate(async () => {
|
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
|
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
|
const { decompress } = await import("/src/game/rom/lz.ts");
|
|
const { SYMBOLS, NATIVE_MAPS } = await import("/src/game/rom/profile.ts");
|
|
const file = (
|
|
document.querySelector("#reference-fixture") as HTMLInputElement
|
|
).files![0];
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
const canvas = document.createElement("canvas");
|
|
const reference = new BinjgbEmulator();
|
|
const assets = new CrystalAssets(bytes);
|
|
await reference.load(bytes, canvas);
|
|
const comparisons: {
|
|
map: string;
|
|
bytes: number;
|
|
equal: boolean;
|
|
returned: boolean;
|
|
}[] = [];
|
|
for (const id of Object.keys(NATIVE_MAPS)) {
|
|
const map = assets.map(id);
|
|
const pointer = assets.reader.far(SYMBOLS.tilesets + map.tileset * 15);
|
|
const bank = Math.floor(pointer / 0x4000),
|
|
address = 0x4000 + (pointer % 0x4000);
|
|
const native = decompress(assets.reader, pointer);
|
|
// Original test trampoline in WRAM: DI; SP=$c7fe; A=bank;
|
|
// HL=compressed ROM address; DE=$d000; CALL FarDecompress ($0b40);
|
|
// write a sentinel and loop. The referenced routine is the user's ROM.
|
|
const program = [
|
|
0xf3,
|
|
0x31,
|
|
0xfe,
|
|
0xc7,
|
|
0x3e,
|
|
bank,
|
|
0x21,
|
|
address & 255,
|
|
address >> 8,
|
|
0x11,
|
|
0,
|
|
0xd0,
|
|
0xcd,
|
|
0x40,
|
|
0x0b,
|
|
0x3e,
|
|
0x42,
|
|
0xea,
|
|
0xff,
|
|
0xc6,
|
|
0x18,
|
|
0xfe,
|
|
];
|
|
reference.writeMemory(0xff70, 6);
|
|
reference.writeMemory(0xff9d, 1);
|
|
reference.writeMemory(0xc6ff, 0);
|
|
program.forEach((value, index) =>
|
|
reference.writeMemory(0xc000 + index, value),
|
|
);
|
|
reference.setProgramCounter(0xc000);
|
|
reference.advanceFrames(30);
|
|
comparisons.push({
|
|
map: id,
|
|
bytes: native.length,
|
|
returned: reference.readMemory(0xc6ff) === 0x42,
|
|
equal: native.every(
|
|
(value, index) => reference.readMemory(0xd000 + index) === value,
|
|
),
|
|
});
|
|
}
|
|
reference.dispose();
|
|
return comparisons;
|
|
});
|
|
expect(result.length).toBe(Object.keys(NATIVE_MAPS).length);
|
|
for (const comparison of result) {
|
|
expect(
|
|
comparison.returned,
|
|
`${comparison.map}: original routine returned`,
|
|
).toBe(true);
|
|
expect(
|
|
comparison.equal,
|
|
`${comparison.map}: ${comparison.bytes} decompressed bytes match`,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("native damage and type effectiveness match the original battle routines", async ({
|
|
page,
|
|
}) => {
|
|
test.skip(
|
|
!existsSync(romPath) || !existsSync(symbolsPath),
|
|
"Requires the local user ROM and the private PRET rebuild.",
|
|
);
|
|
const { at, routine } = loadSymbols();
|
|
const bytes = new Uint8Array(readFileSync(romPath));
|
|
const assets = new CrystalAssets(bytes);
|
|
const data = new BattleData(assets.reader);
|
|
const matchups = data.typeMatchups();
|
|
// A deterministic generator keeps failures reproducible.
|
|
let seed = 0x1234;
|
|
const next = (limit: number) => {
|
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
|
return (seed >>> 8) % limit;
|
|
};
|
|
const types = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 24, 25, 26, 27];
|
|
const effects = [0, 0, 0, C.EFFECT_SELFDESTRUCT, C.EFFECT_MULTI_HIT, C.EFFECT_CONVERSION];
|
|
const damageCases = Array.from({ length: 400 }, () => {
|
|
const item = next(4) === 0 ? next(191) : 0;
|
|
return {
|
|
power: next(8) === 0 ? 0 : next(256),
|
|
level: 1 + next(100),
|
|
attack: next(256),
|
|
defense: next(256),
|
|
effect: effects[next(effects.length)],
|
|
critical: next(2),
|
|
current: next(5) === 0 ? next(1200) : 0,
|
|
type: types[next(types.length)],
|
|
item,
|
|
};
|
|
});
|
|
const stabCases = Array.from({ length: 400 }, () => ({
|
|
move: 1 + next(164),
|
|
type: types[next(types.length)],
|
|
user: [types[next(types.length)], types[next(types.length)]],
|
|
target: [types[next(types.length)], types[next(types.length)]],
|
|
identified: next(4) === 0,
|
|
damage: next(1000),
|
|
}));
|
|
await openReference(page);
|
|
const addresses = {
|
|
curDamage: at("wCurDamage"),
|
|
critical: at("wCriticalHit"),
|
|
turn: at("hBattleTurn"),
|
|
playerMove: at("wPlayerMoveStruct"),
|
|
battleItem: at("wBattleMonItem"),
|
|
battleTypes: at("wBattleMonType1"),
|
|
enemyTypes: at("wEnemyMonType1"),
|
|
typeModifier: at("wTypeModifier"),
|
|
attackMissed: at("wAttackMissed"),
|
|
weather: at("wBattleWeather"),
|
|
johto: at("wJohtoBadges"),
|
|
kanto: at("wKantoBadges"),
|
|
link: at("wLinkMode"),
|
|
tower: at("wInBattleTowerBattle"),
|
|
enemySub1: at("wEnemySubStatus1"),
|
|
damageCalc: routine("BattleCommand_DamageCalc"),
|
|
stab: routine("BattleCommand_Stab"),
|
|
};
|
|
const results = await page.evaluate(
|
|
async ({ addresses, damageCases, stabCases }) => {
|
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
|
const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0];
|
|
const reference = new BinjgbEmulator();
|
|
await reference.load(new Uint8Array(await file.arrayBuffer()), document.createElement("canvas"));
|
|
const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff);
|
|
const word = (address: number, value: number) => {
|
|
write(address, value >> 8);
|
|
write(address + 1, value);
|
|
};
|
|
// Trampoline: DI; SP=$c7fe; switch to the routine's bank; set B, C, D, E;
|
|
// CALL the original routine; write a sentinel; loop.
|
|
const call = (target: { bank: number; address: number }, registers: number[]) => {
|
|
const program = [
|
|
0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20,
|
|
0x06, registers[0], 0x0e, registers[1], 0x16, registers[2], 0x1e, registers[3],
|
|
0xcd, target.address & 0xff, target.address >> 8,
|
|
0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe,
|
|
];
|
|
write(0xc6ff, 0);
|
|
program.forEach((value, index) => write(0xc000 + index, value));
|
|
reference.setProgramCounter(0xc000);
|
|
reference.advanceFrames(2);
|
|
return reference.readMemory(0xc6ff) === 0x42;
|
|
};
|
|
write(0xff70, 1); // WRAM bank 1 holds wCurDamage and wEnemyMon
|
|
for (const address of [addresses.weather, addresses.johto, addresses.kanto, addresses.link, addresses.tower])
|
|
write(address, 0);
|
|
write(addresses.turn, 0);
|
|
const damage = damageCases.map((input) => {
|
|
write(addresses.playerMove + 1, input.effect);
|
|
write(addresses.playerMove + 3, input.type);
|
|
write(addresses.battleItem, input.item);
|
|
write(addresses.critical, input.critical);
|
|
word(addresses.curDamage, input.current);
|
|
// B = attack, C = defense, D = power, E = level
|
|
const returned = call(addresses.damageCalc, [input.attack, input.defense, input.power, input.level]);
|
|
return {
|
|
returned,
|
|
damage: (reference.readMemory(addresses.curDamage) << 8) | reference.readMemory(addresses.curDamage + 1),
|
|
};
|
|
});
|
|
const stab = stabCases.map((input) => {
|
|
write(addresses.playerMove, input.move);
|
|
write(addresses.playerMove + 3, input.type);
|
|
write(addresses.battleTypes, input.user[0]);
|
|
write(addresses.battleTypes + 1, input.user[1]);
|
|
write(addresses.enemyTypes, input.target[0]);
|
|
write(addresses.enemyTypes + 1, input.target[1]);
|
|
write(addresses.enemySub1, input.identified ? 1 << 3 : 0);
|
|
write(addresses.typeModifier, 10);
|
|
write(addresses.attackMissed, 0);
|
|
word(addresses.curDamage, input.damage);
|
|
const returned = call(addresses.stab, [0, 0, 0, 0]);
|
|
return {
|
|
returned,
|
|
damage: (reference.readMemory(addresses.curDamage) << 8) | reference.readMemory(addresses.curDamage + 1),
|
|
modifier: reference.readMemory(addresses.typeModifier),
|
|
missed: reference.readMemory(addresses.attackMissed),
|
|
};
|
|
});
|
|
reference.dispose();
|
|
return { damage, stab };
|
|
},
|
|
{ addresses, damageCases, stabCases },
|
|
);
|
|
damageCases.forEach((input, index) => {
|
|
const original = results.damage[index];
|
|
expect(original.returned, `damage case ${index} returned`).toBe(true);
|
|
const held = data.heldEffect(input.item);
|
|
const boost =
|
|
held.effect && data.typeBoost(held.effect) === input.type ? held.parameter : undefined;
|
|
const native =
|
|
calculateDamage({
|
|
power: input.power,
|
|
level: input.level,
|
|
attack: input.attack,
|
|
defense: input.defense,
|
|
effect: input.effect,
|
|
critical: Boolean(input.critical),
|
|
boost,
|
|
current: input.current,
|
|
}) ?? input.current;
|
|
expect(native, `damage case ${index}: ${JSON.stringify(input)}`).toBe(original.damage);
|
|
});
|
|
stabCases.forEach((input, index) => {
|
|
const original = results.stab[index];
|
|
expect(original.returned, `type case ${index} returned`).toBe(true);
|
|
const native = applyTypeMatchups(matchups, {
|
|
damage: input.damage,
|
|
modifier: 10,
|
|
moveType: input.type,
|
|
userTypes: input.user,
|
|
targetTypes: input.target,
|
|
identified: input.identified,
|
|
});
|
|
expect(
|
|
[native.damage, native.modifier, Number(native.missed)],
|
|
`type case ${index}: ${JSON.stringify(input)}`,
|
|
).toEqual([original.damage, original.modifier, original.missed]);
|
|
});
|
|
});
|
|
|
|
test("native stats and experience curves match the original routines", async ({ page }) => {
|
|
test.skip(
|
|
!existsSync(romPath) || !existsSync(symbolsPath),
|
|
"Requires the local user ROM and the private PRET rebuild.",
|
|
);
|
|
const { at, routine } = loadSymbols();
|
|
const pokemon = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon;
|
|
let seed = 0x5eed;
|
|
const next = (limit: number) => {
|
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
|
return (seed >>> 8) % limit;
|
|
};
|
|
// Legal stats stay below 999; levels above 100 reach the MAX_STAT_VALUE cap.
|
|
const statCases = Array.from({ length: 300 }, () => ({
|
|
base: Array.from({ length: 6 }, () => (next(4) === 0 ? 200 + next(56) : 1 + next(255))),
|
|
level: next(6) === 0 ? 101 + next(155) : next(5) === 0 ? 100 : 1 + next(100),
|
|
dvs: next(0x10000),
|
|
statExp: Array.from({ length: 5 }, () => (next(3) === 0 ? 0xffff - next(256) : next(0x10000))),
|
|
useStatExp: next(4) !== 0,
|
|
}));
|
|
await openReference(page);
|
|
const addresses = {
|
|
baseStats: at("wBaseStats"),
|
|
level: at("wCurPartyLevel"),
|
|
growthRate: at("wBaseGrowthRate"),
|
|
product: at("hProduct"),
|
|
calcMonStats: routine("CalcMonStats"),
|
|
calcExpAtLevel: routine("CalcExpAtLevel"),
|
|
};
|
|
const results = await page.evaluate(
|
|
async ({ addresses, statCases }) => {
|
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
|
const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0];
|
|
const reference = new BinjgbEmulator();
|
|
await reference.load(new Uint8Array(await file.arrayBuffer()), document.createElement("canvas"));
|
|
const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff);
|
|
const read = (address: number, length: number) =>
|
|
Array.from({ length }, (_, i) => reference.readMemory(address + i));
|
|
// Trampoline: DI; SP=$c7fe; switch to the routine's bank; set BC, DE
|
|
// and HL; CALL the original routine; write a sentinel; loop.
|
|
const call = (
|
|
target: { bank: number; address: number },
|
|
bc: number,
|
|
de: number,
|
|
hl: number,
|
|
frames = 2,
|
|
) => {
|
|
const program = [
|
|
0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20,
|
|
0x01, bc & 0xff, bc >> 8, 0x11, de & 0xff, de >> 8, 0x21, hl & 0xff, hl >> 8,
|
|
0xcd, target.address & 0xff, target.address >> 8,
|
|
0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe,
|
|
];
|
|
write(0xc6ff, 0);
|
|
program.forEach((value, index) => write(0xc000 + index, value));
|
|
reference.setProgramCounter(0xc000);
|
|
reference.advanceFrames(frames);
|
|
return reference.readMemory(0xc6ff) === 0x42;
|
|
};
|
|
write(0xff70, 1); // WRAM bank 1 holds the base data and party level
|
|
// A party struct at $c100 (stat experience at +11, DVs at +21) and
|
|
// the six computed stats at $c140.
|
|
const mon = 0xc100,
|
|
out = 0xc140;
|
|
const stats = statCases.map((input) => {
|
|
input.base.forEach((value, i) => write(addresses.baseStats + i, value));
|
|
write(addresses.level, input.level);
|
|
input.statExp.forEach((value, i) => {
|
|
write(mon + 11 + i * 2, value >> 8);
|
|
write(mon + 12 + i * 2, value);
|
|
});
|
|
write(mon + 21, input.dvs >> 8);
|
|
write(mon + 22, input.dvs);
|
|
// B = stat experience flag; HL = MON_STAT_EXP - 1; DE = output.
|
|
// GetSquareRoot counts up to 255 per stat, so allow more frames.
|
|
const returned = call(addresses.calcMonStats, Number(input.useStatExp) << 8, out, mon + 10, 8);
|
|
const bytes = read(out, 12);
|
|
return { returned, stats: Array.from({ length: 6 }, (_, i) => (bytes[i * 2] << 8) | bytes[i * 2 + 1]) };
|
|
});
|
|
const experience: { returned: boolean; exp: number }[] = [];
|
|
for (let growth = 0; growth < 6; growth++)
|
|
for (let level = 1; level <= 100; level++) {
|
|
write(addresses.growthRate, growth);
|
|
// D = level
|
|
const returned = call(addresses.calcExpAtLevel, 0, level << 8, 0);
|
|
const [high, middle, low] = read(addresses.product + 1, 3);
|
|
experience.push({ returned, exp: (high << 16) | (middle << 8) | low });
|
|
}
|
|
reference.dispose();
|
|
return { stats, experience };
|
|
},
|
|
{ addresses, statCases },
|
|
);
|
|
statCases.forEach((input, index) => {
|
|
const original = results.stats[index];
|
|
expect(original.returned, `stat case ${index} returned`).toBe(true);
|
|
const native = input.base.map((base, stat) =>
|
|
calcStat(base, input.dvs, stat, input.level, input.useStatExp ? input.statExp : undefined),
|
|
);
|
|
expect(native, `stat case ${index}: ${JSON.stringify(input)}`).toEqual(original.stats);
|
|
});
|
|
expect(results.stats.some(({ stats }) => stats.includes(999))).toBe(true);
|
|
results.experience.forEach((original, index) => {
|
|
const growth = Math.floor(index / 100),
|
|
level = (index % 100) + 1;
|
|
expect(original.returned, `experience ${growth}/${level} returned`).toBe(true);
|
|
expect(pokemon.expAtLevel(growth, level), `experience ${growth}/${level}`).toBe(original.exp);
|
|
});
|
|
});
|