Files
Crystal/tests/reference-hall-of-fame.spec.ts
kawaandClaude Opus 5.5 e49bc6de9a Breed at the Day-Care, hatch Eggs, draw MAIL stationery and browse the Hall of Fame (Closes #29)
- Day-Care and breeding: deposits, withdrawal pricing and growth,
  compatibility, egg species, DVs and moves, DayCareStep, egg
  collection and the Odd Egg, compared with the cartridge's routines.
- CountStep: walking happiness, DoEggStep, DayCareStep and DoBikeStep
  in the original's order; HatchEggs with the ROM's hatch animation,
  compared frame by frame with the original.
- MAIL: all ten stationery types and the Portrait MAIL picture,
  compared with the cartridge's tilemaps, graphics and palettes.
- Printing: Bill's PC boxes, Pokedex PRNT, MAIL and the Photo Studio
  take the original's absent-printer path.
- Hall of Fame records (saved apart from the League flag, ready for the
  League to call) and the Hall of Fame PC, compared with the cartridge's
  _HallOfFamePC on 255 pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 08:52:30 +02:00

234 lines
7.1 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/reference.",
);
test("Hall of Fame PC reads SRAM and matches the resting tilemap, picture and palette", async ({
page,
}) => {
const { at, routine } = loadSymbols();
const flat = (name: string) => {
const { bank, address } = routine(name);
return address < 0x4000 ? address : bank * 0x4000 + address - 0x4000;
};
const addresses = {
romBank: at("hROMBank"),
cgb: at("hCGB"),
joy: at("hJoyLast"),
tilemap: at("wTilemap"),
attrs: at("wAttrmap"),
palettes: at("wBGPals1"),
frame: at("wTextboxFrame"),
hof: at("sHallOfFame"),
};
const pc = routine("_HallOfFamePC");
const font = routine("_LoadStandardFont");
// Only bypass frame waits and frontpic animation: the native viewer
// presents the resting image. All SRAM decoding, layout, text, gender,
// frontpic decompression and palette selection execute cartridge code.
const skip = [
"WaitBGMap",
"DelayFrame",
"HOF_AnimateFrontpic",
"JoyTextDelay",
].map(flat);
await openReference(page);
const result = await page.evaluate(
async ({ addresses: a, pc, font, skip }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { hallOfFameScreen } =
await import("/src/game/scripts/hall-of-fame.ts");
const { textTiles } = await import("/src/game/rom/font.ts");
const { rgb } = await import("/src/game/scripts/screen.ts");
const rom = new Uint8Array(
await (
document.querySelector("#reference-fixture") as HTMLInputElement
).files![0].arrayBuffer(),
);
const assets = new CrystalAssets(rom);
for (const p of skip) rom[p] = 0xc9;
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const write = (p: number, n: number) => reference.writeMemory(p, n & 255);
const read = (p: number) => reference.readMemory(p);
const cases = [
{
species: 25,
dvs: 0xaaaa,
level: 42,
nickname: "SPARK",
otId: 12345,
win: 1,
},
{
species: 201,
dvs: 0xffff,
level: 100,
nickname: "UNOWN",
otId: 0,
win: 200,
},
{
species: 29,
dvs: 0,
level: 5,
nickname: "NIDORAN♀",
otId: 65535,
win: 99,
},
{
species: 157,
dvs: 0xffff,
level: 99,
nickname: "FIRE",
otId: 42,
win: 100,
},
...Array.from({ length: 251 }, (_, i) => ({
species: i + 1,
dvs: i % 2 ? 0x2aaa : 0xffff,
level: 1 + (i % 100),
nickname: "RECORDED",
otId: i * 251,
win: 1 + (i % 200),
})),
];
const failures: unknown[] = [];
for (const [index, input] of cases.entries()) {
const { win, ...mon } = input;
const team = { win, mons: [mon] };
// SRAM bank 1: one team, a terminator after its first Pokémon, and
// the absent next team. The PC must load this itself.
write(0, 0x0a);
write(0x4000, 1);
for (let i = 0; i < 196; i++) write(a.hof + i, 0);
const record = [
win,
mon.species,
mon.otId >> 8,
mon.otId & 255,
mon.dvs >> 8,
mon.dvs & 255,
mon.level,
...textTiles(mon.nickname),
...Array(10 - mon.nickname.length).fill(0x50),
0xff,
];
record.forEach((value, offset) => write(a.hof + offset, value));
write(0, 0);
write(0xff70, 1);
write(0xff40, 0);
write(a.cgb, 1);
write(a.frame, index % 8);
write(a.joy, 2); // B after the first page.
// JoyTextDelay is stubbed to preserve the fixed B input above.
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0x3e,
font.bank,
0xe0,
a.romBank & 255,
0xea,
0,
0x20,
0xcd,
font.address & 255,
font.address >> 8,
0x3e,
pc.bank,
0xe0,
a.romBank & 255,
0xea,
0,
0x20,
0xcd,
pc.address & 255,
pc.address >> 8,
0x3e,
0x42,
0xea,
0xf0,
0xc0,
0x18,
0xfe,
];
write(0xc0f0, 0);
program.forEach((value, offset) => write(0xc000 + offset, value));
reference.setProgramCounter(0xc000);
for (let frame = 0; frame < 100 && read(0xc0f0) !== 0x42; frame++)
reference.advanceFrames(1);
const native = hallOfFameScreen(assets.pokemon, team, 0, index % 8);
const original = Array.from({ length: 360 }, (_, i) =>
read(a.tilemap + i),
);
const mismatches = original.flatMap((tile, i) =>
tile === native.map[i]
? []
: [{ i, original: tile, native: native.map[i] }],
);
const attrDiff = Array.from({ length: 360 }, (_, i) =>
read(a.attrs + i),
).filter((v, i) => v !== native.attrs[i]).length;
// CGB palette work RAM is bank 5; graphics at $9000 are VRAM bank 0.
write(0xff70, 5);
const palette = Array.from({ length: 4 }, (_, i) =>
rgb(read(a.palettes + i * 2) | (read(a.palettes + i * 2 + 1) << 8)),
);
write(0xff70, 1);
write(0xff4f, 0);
const picture = Array.from({ length: 49 * 16 }, (_, i) =>
read(0x9000 + i),
);
const picDiff = picture.filter((v, i) => v !== native.tiles[i]).length;
const tileDiff = [...new Set(native.map)]
.flatMap((tile) =>
Array.from({ length: 16 }, (_, byte) => ({
tile,
byte,
expected: native.tiles[tile * 16 + byte],
actual: read(
(tile < 128 ? 0x9000 : 0x8800) + (tile % 128) * 16 + byte,
),
})),
)
.filter(({ expected, actual }) => expected !== actual);
if (
read(0xc0f0) !== 0x42 ||
mismatches.length ||
attrDiff ||
picDiff ||
tileDiff.length ||
JSON.stringify(palette) !== JSON.stringify(native.palettes[0])
)
failures.push({
input,
returned: read(0xc0f0) === 0x42,
mismatches: mismatches.slice(0, 12),
attrDiff,
picDiff,
tileDiff: tileDiff.slice(0, 5),
palette,
nativePalette: native.palettes[0],
});
}
reference.dispose();
return { cases: cases.length, failures };
},
{ addresses, pc, font, skip },
);
expect(result.failures, JSON.stringify(result)).toEqual([]);
});