Files
Crystal/tests/reference-parity.spec.ts
T
Sébastien QUEROLandClaude Opus 5.5 796fe8f6d5
Checks / web (push) Canceled after 0s
Open Route 29 and Cherrygrove City
- Enable Route 29, its Route 46 gate, Cherrygrove City and its buildings.
- Run item balls through FindItemInBallScript and fruit trees through
  FruitTreeScript, with a native daily reset of the daily engine flags.
- Port the Pokémon Center nurse (HealParty with the PP Up clamp,
  CheckPokerus, phone-call checks) and record the whiteout spawn on
  entering a Pokémon Center, as EnterMapWarp does.
- Port standard Poké Marts with keyboard, D-pad and pointer menus.
- Add the recoil battle command (Struggle) and VAR_BOXSPACE.
- Save fruit trees, the daily reset day and the last spawn map; older
  version-3 backups migrate by absence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 17:25:02 +02:00

297 lines
11 KiB
TypeScript

import { test, expect } 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 { resolve } from "node:path";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
test("native decompression matches original GB assembly executing in the reference emulator", async ({
page,
}) => {
test.skip(!existsSync(romPath), "Requires the local user ROM.");
await page.goto("/reference.html");
// File chooser supplies bytes to an isolated test input, without an HTTP ROM endpoint.
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);
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,
}) => {
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Requires the local user ROM and the private PRET rebuild.",
);
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]
: [];
}),
);
const at = (name: string) => symbols.get(name)!.address;
const routine = (name: string) => symbols.get(name)!;
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 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);
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]);
});
});