- 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>
234 lines
7.1 KiB
TypeScript
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([]);
|
|
});
|