Files
Crystal/tests/reference-hall-of-fame-ceremony.spec.ts
T
kawaandClaude Opus 5.5 f05fd8ebee
Checks / web (push) Failing after 18s
Private ROM checks / private-rom (push) Failing after 25m31s
Open the League: Victory Road, the Elite Four, the Hall of Fame and the credits (#33)
Routes 26 and 27 with their houses, Tohjo Falls, the Victory Road gate,
Victory Road, Route 23, Indigo Plateau, the Elite Four's rooms, Lance's
room and the Hall of Fame are open.

- halloffame (game/scripts/league.ts): the music fades as SAVING RECORD
  shows, the win and the team (without Eggs) are recorded, each Pokémon's
  back picture passes and its front picture slides in for its "New Hall of
  Famer!" page and cry, then the player's card with the play time and
  Professor Oak's rating; the play time stops meanwhile. The world then
  continues at New Bark as Continue does after the cartridge's reset.
- credits and the credits after the Hall of Fame (CreditsMachine): the
  ROM's script, strings, banner Pokémon and THE END, frame by frame as
  VBlank_Credits presents them, with the frames each command group runs
  over measured on the cartridge. B shortens credits seen before.
- wait, appear/disappear of the player, and rival names in battle texts
  (PlaceEnemysName prints wRivalName alone: "???" before he is named).
- The tile-screen renderer wraps hSCX/hSCY at 256 pixels and gives rows
  their own hSCX.
- Reference: the credits match the cartridge on every frame (BG map,
  banner tiles, CRAM palettes, LY overrides, parser), with and without B;
  the ceremony's pages match for every species and the player's card for
  Chris and Kris.
- Playthrough: five stages from Tin Tower to the Hall of Fame (HM07 from
  Ice Path, Tohjo Falls' waterfall, Victory Road's ladders, the Elite Four
  and Lance); the driver follows warps within a map and fights carefully
  from here.

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

184 lines
8.9 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.");
/** The cartridge's AnimateHallOfFame.DisplayNewHallOfFamer and
* HOF_AnimatePlayerPic run with the LCD off (so graphics load at once) and
* only their frame waits, HOF_AnimateFrontpic and ProfOaksPCRating
* stubbed; the tilemap, attributes, BG palette 0 and the tiles each screen
* shows are compared with the native screens. */
test("the Hall of Fame ceremony's pages match the cartridge's tilemaps, tiles and palettes", async ({ page }) => {
test.setTimeout(120_000);
const { at, routine } = loadSymbols();
const flat = (name: string) => {
const { bank, address } = routine(name);
return address < 0x4000 ? address : bank * 0x4000 + address - 0x4000;
};
const a = {
romBank: at("hROMBank"),
cgb: at("hCGB"),
tilemap: at("wTilemap"),
attrs: at("wAttrmap"),
palettes: at("wBGPals1"),
frame: at("wTextboxFrame"),
temp: at("wHallOfFameTempMon1"),
name: at("wPlayerName"),
id: at("wPlayerID"),
hours: at("wGameTimeHours"),
minutes: at("wGameTimeMinutes"),
gender: at("wPlayerGender"),
layout: at("GetSGBLayout"),
setupFlags: at("wPlayerSpriteSetupFlags"),
species: at("wCurPartySpecies"),
tempDVs: at("wTempMonDVs"),
};
const routines = {
font: routine("_LoadStandardFont"),
extra: routine("LoadFontsBattleExtra"),
famer: routine("AnimateHallOfFame.DisplayNewHallOfFamer"),
player: routine("HOF_AnimatePlayerPic"),
};
const skip = ["WaitBGMap", "WaitBGMap2", "DelayFrame", "DelayFrames", "HOF_AnimateFrontpic", "ProfOaksPCRating"].map(flat);
await openReference(page);
const result = await page.evaluate(
async ({ a, routines, skip, get2bpp, request2bpp }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { newHallOfFamerScreen, hallOfFamePlayerCard } = await import("/src/game/scripts/league.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;
// Request2bpp waits for VBlank even with the LCD off; Get2bpp copies
// at once then, from the same arguments.
rom.set([0xc3, get2bpp & 255, get2bpp >> 8], request2bpp);
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 call = (r: { bank: number; address: number }) => [
0x3e, r.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
0xcd, r.address & 255, r.address >> 8,
];
/** Run the font loaders, the routine (HL = `hl`), then a sentinel;
* with `layout`, GetSGBLayout's SCGB_PLAYER_OR_MON_FRONTPIC_PALS
* first, as AnimateHOFMonEntrance applies it for the same Pokémon
* before DisplayNewHallOfFamer. */
const run = (target: { bank: number; address: number }, hl = 0, layout = false) => {
write(0xff70, 1);
write(0xff40, 0);
write(a.cgb, 1);
const program = [
0xf3, 0x31, 0xfe, 0xc7,
...call(routines.font),
...call(routines.extra),
...(layout ? [0x06, 0x1a, 0xcd, a.layout & 255, a.layout >> 8] : []),
0x21, hl & 255, hl >> 8,
...call(target),
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 < 200 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
return read(0xc0f0) === 0x42;
};
type Screen = { map: number[]; attrs: number[]; palettes: number[][][]; tiles: Uint8Array };
const compare = (label: unknown, native: Screen, tiles: number[]) => {
const map = Array.from({ length: 360 }, (_, i) => read(a.tilemap + i));
// The attributes shown: WaitBGMap copies tiles only, so the text
// boxes' palette in wAttrmap stays off screen; GetSGBLayout's
// ApplyAttrmap set VRAM bank 1.
write(0xff4f, 1);
const attrs = Array.from({ length: 360 }, (_, i) => read(0x9800 + Math.floor(i / 20) * 32 + (i % 20)));
write(0xff4f, 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 tileDiff = tiles.flatMap((tile) =>
Array.from({ length: 16 }, (_, byte) => {
const actual = read((tile < 128 ? 0x9000 : 0x8800) + (tile % 128) * 16 + byte);
return actual === native.tiles[tile * 16 + byte] ? [] : [{ tile, byte, actual, expected: native.tiles[tile * 16 + byte] }];
}).flat(),
);
const mapDiff = map.flatMap((tile, i) => (tile === native.map[i] ? [] : [{ i, original: tile, native: native.map[i] }]));
const attrDiff = attrs.filter((v, i) => v !== native.attrs[i]).length;
const paletteDiff = JSON.stringify(palette) !== JSON.stringify(native.palettes[0]);
return mapDiff.length || attrDiff || paletteDiff || tileDiff.length
? [{ label, mapDiff: mapDiff.slice(0, 10), attrDiff, palette, nativePalette: native.palettes[0], tileDiff: tileDiff.slice(0, 5) }]
: [];
};
const failures: unknown[] = [];
const mons = [
{ species: 25, dvs: 0xaaaa, level: 42, nickname: "SPARK", otId: 12345 },
{ species: 201, dvs: 0xffff, level: 100, nickname: "UNOWN", otId: 0 },
{ species: 29, dvs: 0, level: 5, nickname: "NIDORAN♀", otId: 65535 },
{ species: 157, dvs: 0xfaaa, level: 64, nickname: "TYPHLOSION", otId: 35209 },
...Array.from({ length: 251 }, (_, i) => ({
species: i + 1,
dvs: i % 3 ? 0x2aaa : 0xffff,
level: 1 + (i % 100),
nickname: "INDUCTED",
otId: i * 257,
})),
];
for (const [index, mon] of mons.entries()) {
const record = [
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),
];
record.forEach((value, offset) => write(a.temp + offset, value));
write(a.species, mon.species);
write(a.tempDVs, mon.dvs >> 8);
write(a.tempDVs + 1, mon.dvs & 255);
write(a.frame, index % 8);
const returned = run(routines.famer, a.temp, true);
const native = newHallOfFamerScreen(assets.pokemon, mon, index % 8);
const tiles = [...new Set(native.map)];
failures.push(...(returned ? compare(mon, native, tiles) : [{ mon, returned }]));
}
const players = [
{ name: "KAWA", id: 35209, female: false, time: { hours: 1, minutes: 23 } },
{ name: "KRIS", id: 0, female: true, time: { hours: 999, minutes: 59 } },
{ name: "ABCDEFG", id: 65535, female: false, time: { hours: 0, minutes: 0 } },
{ name: "ᴾᴷᴹᴺ", id: 42, female: true, time: { hours: 42, minutes: 7 } },
];
for (const [index, player] of players.entries()) {
write(0xff70, 1);
const name = [...textTiles(player.name), 0x50];
name.forEach((value, offset) => write(a.name + offset, value));
write(a.id, player.id >> 8);
write(a.id + 1, player.id & 255);
write(a.hours, player.time.hours >> 8);
write(a.hours + 1, player.time.hours & 255);
write(a.minutes, player.time.minutes);
write(a.gender, player.female ? 1 : 0);
write(a.setupFlags, 0);
write(a.frame, index % 8);
const returned = run(routines.player);
const native = hallOfFamePlayerCard(
assets.pokemon,
{ ...player, time: { ...player.time, seconds: 0, frames: 0, capped: false } },
index % 8,
);
// The card's tiles, and the back picture loaded from $31.
const tiles = [...new Set([...native.map, ...Array.from({ length: 36 }, (_, i) => 0x31 + i)])];
failures.push(...(returned ? compare(player, native, tiles) : [{ player, returned }]));
}
reference.dispose();
return { mons: mons.length, failures };
},
{ a, routines, skip, get2bpp: at("Get2bpp"), request2bpp: flat("Request2bpp") },
);
expect(result.failures, JSON.stringify(result.failures.slice(0, 3))).toEqual([]);
});