Files
Crystal/tests/reference-badges.spec.ts
T
Sébastien QUEROLandClaude Opus 5.5 ec3d1cd5c4 Port the Johto badge stat boosts (#31)
BadgeStatBoosts runs where the original calls it: when a Pokémon is sent out, after an in-battle level-up, and in CalcPlayerStats. It keeps the PlainBadge/MineralBadge swap and the Glacier Badge's Special Defense register bug. A transformed Pokémon that levels up now keeps its copied stats.

The reference emulator compares all 256 badge bytes over 28 stat sets per browser against the unmodified routine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:08:31 +02:00

70 lines
3.5 KiB
TypeScript

import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols.");
test("badge stat boosts match the original for every Johto badge byte", async ({ page }) => {
const { at, routine } = loadSymbols();
const addresses = {
romBank: at("hROMBank"),
badges: at("wJohtoBadges"),
linkMode: at("wLinkMode"),
tower: at("wInBattleTowerBattle"),
stats: at("wBattleMonAttack"),
};
const boosts = routine("BadgeStatBoosts");
await openReference(page);
const result = await page.evaluate(async ({ a, boosts }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { Battle } = await import("/src/game/battle/battle.ts");
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
const assets = new CrystalAssets(rom);
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
const read = (p: number) => reference.readMemory(p);
// DI; SP=$c7fe; select the bank; CALL BadgeStatBoosts; sentinel; loop.
// The sentinel lives in audio RAM, away from the battle variables.
const program = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, boosts.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
0xcd, boosts.address & 255, boosts.address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
// Around the cap, a range of Special Attack values for the Glacier
// Badge's register bug, and small stats.
const statSets = [
[1, 2, 3, 4, 5],
[10, 20, 30, 40, 50],
[887, 888, 889, 900, 998],
[999, 999, 999, 999, 999],
...Array.from({ length: 24 }, (_, i) => [100, 150, 200, 180 + i * 30, 300]),
];
const failures: unknown[] = [];
let compared = 0;
for (let badges = 0; badges < 256; badges++)
for (const stats of statSets) {
const battle = new Battle({
data: assets.pokemon, party: [], pokedex: { seen: new Set(), caught: new Set() }, playerId: 1,
playerName: "CHRIS", timeOfDay: 1, random: () => 0, text: () => ({}) as never, johtoBadges: badges,
});
battle.player.mon = { stats: [...stats] } as never;
(battle as unknown as { badgeStatBoosts(): void }).badgeStatBoosts();
write(a.badges, badges);
write(a.linkMode, 0);
write(a.tower, 0);
stats.forEach((value, i) => { write(a.stats + i * 2, value >> 8); write(a.stats + i * 2 + 1, value); });
write(0xc0f0, 0);
program.forEach((value, i) => write(0xc000 + i, value));
reference.setProgramCounter(0xc000);
reference.advanceFrames(1);
if (read(0xc0f0) !== 0x42) throw Error("BadgeStatBoosts did not return");
const original = stats.map((_, i) => (read(a.stats + i * 2) << 8) | read(a.stats + i * 2 + 1));
const native = battle.player.mon.stats;
if (original.some((value, i) => value !== native[i])) failures.push({ badges, stats, original, native });
compared++;
}
reference.dispose();
return { compared, failures: failures.slice(0, 5), count: failures.length };
}, { a: addresses, boosts });
expect(result.compared).toBe(256 * 28);
expect(result, JSON.stringify(result)).toMatchObject({ count: 0 });
});