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>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-17 08:52:30 +02:00
1 parent 7d03f5c5ca
commit e49bc6de9a
49 files changed
+6028 -88

No files matched your search

+201
View File
@@ -0,0 +1,201 @@
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("all ten MAIL screens match cartridge tilemaps, graphics, message placement and palettes", async ({
page,
}) => {
const { at, routine } = loadSymbols();
const labels = [
"Flower",
"Surf",
"LiteBlue",
"Portrait",
"Lovely",
"Eon",
"Morph",
"BlueSky",
"Music",
"Mirage",
];
const defs = labels.map((name) => routine(`Load${name}MailGFX`));
const a = Object.fromEntries(
[
"hROMBank",
"hCGB",
"wTilemap",
"wAttrmap",
"wCurPartySpecies",
"wUnownLetter",
"wCurMailIndex",
"wBoxAlignment",
"wBGPals1",
"sPartyMon1Mail",
"sPartyMon1MailAuthor",
].map((n) => [n, at(n)]),
);
const skip = ["ApplyPals", "ApplyAttrmap"].map(routine);
const palette = routine("LoadMailPalettes");
await openReference(page);
const result = await page.evaluate(
async ({ defs, a, skip, palette }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { mailScreen } = await import("/src/game/scripts/mail.ts");
const { textTiles } = await import("/src/game/rom/font.ts");
const { MAIL } = await import("/src/game/rom/profile.ts");
const rom = new Uint8Array(
await (
document.querySelector("#reference-fixture") as HTMLInputElement
).files![0].arrayBuffer(),
);
const assets = new CrystalAssets(rom);
// Exclude hardware palette transfer only. The original graphics,
// message/author placement and palette selection execute.
for (const { bank, address } of skip)
rom[bank * 0x4000 + address - 0x4000] = 0xc9;
const ref = new BinjgbEmulator();
await ref.load(rom, document.createElement("canvas"));
const write = (p: number, v: number) => ref.writeMemory(p, v & 255);
const read = (p: number) => ref.readMemory(p);
const call = (routine: { bank: number; address: number }, e = 0) => {
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0xaf,
0xe0,
0x40,
0xe0,
0x4f,
0x3e,
routine.bank,
0xe0,
a.hROMBank & 255,
0xea,
0,
0x20,
0x01,
a.sPartyMon1Mail & 255,
a.sPartyMon1Mail >> 8,
0x1e,
e,
0xcd,
routine.address & 255,
routine.address >> 8,
0x3e,
0x42,
0xea,
0xf0,
0xc0,
0x18,
0xfe,
];
write(0xc0f0, 0);
program.forEach((v, i) => write(0xc000 + i, v));
ref.setProgramCounter(0xc000);
for (let f = 0; f < 60 && read(0xc0f0) !== 0x42; f++)
ref.advanceFrames(1);
if (read(0xc0f0) !== 0x42)
throw Error(
`MAIL routine ${routine.address.toString(16)} did not return`,
);
};
const failures: unknown[] = [];
for (let index = 0; index < defs.length; index++) {
write(0xff40, 0);
write(0xff4f, 0);
write(0xff70, 1);
write(a.hCGB, 1);
for (let p = 0x8000; p < 0xa000; p++) write(p, 0);
for (let i = 0; i < 360; i++) write(a.wTilemap + i, 0x7f);
write(a.wCurMailIndex, index);
write(a.wBoxAlignment, 0);
write(a.wCurPartySpecies, 201);
write(a.wUnownLetter, 1);
const message = "“HELLO…” FRIEND! ";
write(0x0000, 0x0a);
write(0x4000, 0);
const bytes = [
...textTiles(message),
0x4e,
...textTiles("SEE YOU SOON!"),
0x50,
];
for (let i = 0; i < 47; i++) write(a.sPartyMon1Mail + i, 0x50);
bytes.forEach((v, i) => write(a.sPartyMon1Mail + i, v));
textTiles("CHRIS").forEach((v, i) =>
write(a.sPartyMon1MailAuthor + i, v),
);
call(defs[index]);
const native = mailScreen(assets.pokemon, {
message: message + "\nSEE YOU SOON!",
author: "CHRIS",
authorId: 1,
species: 201,
type: assets.reader.byte(MAIL.MailGFXPointers + index * 3),
});
const map = Array.from({ length: 360 }, (_, i) => read(a.wTilemap + i));
const mapDiff = map.flatMap((v, i) =>
v === native.map[i]
? []
: [{ i, original: v, native: native.map[i] }],
);
const tileDiff: unknown[] = [];
for (const tile of new Set(map)) {
if (tile >= 0x60) continue; // font is loaded by ReadAnyMail, before these routines
for (let b = 0; b < 16; b++)
if (read(0x9000 + tile * 16 + b) !== native.tiles[tile * 16 + b]) {
tileDiff.push({
tile,
b,
original: read(0x9000 + tile * 16 + b),
native: native.tiles[tile * 16 + b],
});
break;
}
}
call(palette, index);
write(0xff70, 5);
const colors = Array.from(
{ length: 4 },
(_, i) =>
read(a.wBGPals1 + i * 2) | (read(a.wBGPals1 + i * 2 + 1) << 8),
);
const rgb = colors.map((v) =>
[v & 31, (v >> 5) & 31, (v >> 10) & 31].map((c) =>
Math.round((c * 255) / 31),
),
);
write(0xff70, 1);
if (
mapDiff.length ||
tileDiff.length ||
JSON.stringify(rgb) !== JSON.stringify(native.palettes[0])
)
failures.push({
index,
mapDiff: mapDiff.slice(0, 12),
tileDiff: tileDiff.slice(0, 12),
rgb,
nativePalette: native.palettes[0],
});
}
ref.dispose();
return { count: defs.length, failures };
},
{ defs, a, skip, palette },
);
expect(result.count).toBe(10);
expect(result.failures).toEqual([]);
});