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

+346
View File
@@ -0,0 +1,346 @@
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.",
);
for (const [species, dvs] of [
[175, 0x8888],
[172, 0x2aaa],
])
test(`hatch ${species}/${dvs} wobble and shell frames match original OAM, tilemaps and palettes`, async ({
page,
}) => {
test.setTimeout(240_000);
const { at, routine } = loadSymbols();
const names = [
"wNamedObjectIndex",
"wCurSpecies",
"wCurPartySpecies",
"wCurPartyMon",
"wPartyMon1DVs",
"wBoxAlignment",
"hSCX",
"wGlobalAnimXOffset",
"wFrameCounter",
"wShadowOAM",
"wBGPals1",
"wBGPals2",
"wOBPals1",
"wOBPals2",
"hCGB",
"hBGMapMode",
"hVBlank",
"hROMBank",
"wPartyCount",
"wPartySpecies",
"wTimeOfDay",
"wPlayerHPPal",
"wTilemap",
"wAttrmap",
"wSpriteAnimationStructs",
"wSpriteAnimCount",
];
const a: Record<string, number> = {};
for (const n of names) a[n] = at(n);
a.DelayFrame = routine("DelayFrame").address;
const seq = routine("EggHatch_AnimationSequence");
await openReference(page);
const result = await page.evaluate(
async ({ a, seq, species, dvs }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const rom = new Uint8Array(
await (
document.querySelector("#reference-fixture") as HTMLInputElement
).files![0].arrayBuffer(),
);
// Count DelayFrame calls: its first five bytes (ld a, 1; ld [wVBlankOccurred], a)
// jump to a hook in the unused end of bank 0 that bumps a counter at $c6f1.
const expected = [0x3e, 0x01, 0xea, 0xb3, 0xcf, 0x76];
expected.forEach((v, i) => {
if (rom[0x45a + i] !== v)
throw Error("DelayFrame is not where expected");
});
[0xc3, 0xc0, 0x3f, 0x00, 0x00].forEach((v, i) => (rom[0x45a + i] = v));
[
0xfa, 0xf1, 0xc6, 0x3c, 0xea, 0xf1, 0xc6, 0x3e, 0x01, 0xea, 0xb3,
0xcf, 0xc3, 0x5f, 0x04,
].forEach((v, i) => (rom[0x3fc0 + i] = v));
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { hatchAnimation, shellSprites, hatchScreen } =
await import("/src/game/scripts/hatch-animation.ts");
const assets = new CrystalAssets(rom);
const { SYMBOLS } = await import("/src/game/rom/profile.ts");
const animation = hatchAnimation(assets.pokemon, species, dvs);
const native: {
map: number[];
attrs: number[];
oam: number[];
scx: number;
palette: number[][];
}[] = [];
const oam = (
sprites: {
x: number;
y: number;
tile: number;
flipX?: boolean;
flipY?: boolean;
palette: number;
}[],
) => {
const bytes = Array<number>(160).fill(0);
sprites.forEach((s, i) =>
bytes.splice(
i * 4,
4,
(s.y + 16) & 255,
(s.x + 8) & 255,
s.tile - 256,
s.palette | (s.flipX ? 32 : 0) | (s.flipY ? 64 : 0),
),
);
return bytes;
};
for (const effect of animation)
if (effect.kind === "screen")
native.push({
map: [...effect.screen.map],
attrs: [...effect.screen.attrs],
oam: oam(effect.screen.sprites),
scx: (effect.screen.scroll?.x ?? 0) & 255,
palette: effect.screen.palettes[0],
});
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);
// Boot to the intro so the game's Init has run.
reference.advanceFrames(240);
// _CGB_Evolution keeps OBJ palettes 0/1 from the overworld.
// Seed daylight palettes exactly as LoadMapPals would.
write(0xff70, 5);
for (let i = 0; i < 16; i++)
write(
0xd040 + i,
assets.reader.byte(SYMBOLS.objectPalettes + 64 + i),
);
write(0xff70, 1);
write(a.hVBlank, 0);
write(a.wNamedObjectIndex, species);
write(a.wCurSpecies, species);
write(a.wCurPartySpecies, species);
write(a.wCurPartyMon, 0);
write(a.wPartyCount, 1);
write(a.wPartySpecies, species);
write(a.wPartySpecies + 1, 0xff);
write(a.wPartyMon1DVs, dvs >> 8);
write(a.wPartyMon1DVs + 1, dvs);
write(a.wBoxAlignment, 0);
write(a.hBGMapMode, 0);
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0x3e,
seq.bank,
0xe0,
a.hROMBank & 255,
0xea,
0,
0x20,
0xfb, // EI
0xcd,
seq.address & 255,
seq.address >> 8,
0xf3,
0x3e,
0x42,
0xea,
0xf0,
0xc6,
0x18,
0xfe,
];
write(0xc6f0, 0);
program.forEach((v, i) => write(0xc700 + i, v));
// Run the real animation, sampling its DelayFrame calls.
reference.setProgramCounter(0xc700);
const frames: Record<string, unknown>[] = [];
const log: string[] = [];
// One sample per DelayFrame call: within 100 cycles of the counter changing.
let lastCount = read(0xc6f1);
const nextDelayFrame = () => {
for (let guard = 0; guard < 200000; guard++) {
reference.advanceCycles(100);
const count = read(0xc6f1);
if (count !== lastCount) {
lastCount = count;
return true;
}
if (read(0xc6f0) === 0x42) return false;
}
return false;
};
for (let f = 0; f < 1200; f++) {
if (!nextDelayFrame()) {
log.push(`no DelayFrame at ${f}`);
break;
}
const done = read(0xc6f0) === 0x42;
const oam: number[] = [];
for (let i = 0; i < 160; i++) oam.push(read(a.wShadowOAM + i));
const map: number[] = [];
for (let i = 0; i < 360; i++) map.push(read(a.wTilemap + i));
const attrs: number[] = [];
for (let i = 0; i < 360; i++) attrs.push(read(a.wAttrmap + i));
const bg: number[] = [],
ob: number[] = [],
bg2: number[] = [],
ob2: number[] = [];
write(0xff70, 5);
for (let i = 0; i < 64; i++) {
bg.push(read(0xd000 + i));
ob.push(read(0xd040 + i));
bg2.push(read(0xd080 + i));
ob2.push(read(0xd0c0 + i));
}
write(0xff70, 1);
const structs: number[] = [];
for (let i = 0; i < 160; i++)
structs.push(read(a.wSpriteAnimationStructs + i));
const rec = {
f,
structs,
bg2,
ob2,
ly: read(0xff44),
scx: read(0xff43),
hscx: read(a.hSCX),
gx: read(a.wGlobalAnimXOffset),
fc: read(a.wFrameCounter),
lcdc: read(0xff40),
bgmode: read(a.hBGMapMode),
oam,
map,
attrs,
bg,
ob,
done,
};
frames.push(rec);
if (done) {
log.push(`done at ${f}`);
break;
}
}
reference.dispose();
const errors: unknown[] = [];
const eq = (
actual: unknown,
expected: unknown,
phase: string,
frame: number,
) => {
if (
JSON.stringify(actual) !== JSON.stringify(expected) &&
errors.length < 5
)
errors.push({ phase, frame, actual, expected });
};
const trace = frames as {
f: number;
fc: number;
hscx: number;
oam: number[];
map: number[];
attrs: number[];
bg: number[];
structs: number[];
}[];
const start = trace.findIndex(
(f) => f.fc === 1 && f.hscx === 2 && f.map[87] === 49,
);
if (start < 0) throw Error("No wobble began");
for (let frame = 0; frame < 344; frame++) {
const ref = trace[start + frame],
local = native[80 + frame];
eq(ref.oam, local.oam, "wobble OAM", frame);
eq(ref.map, local.map, "wobble tilemap", frame);
eq(ref.attrs, local.attrs, "wobble attrs", frame);
eq(ref.hscx, local.scx, "wobble scroll", frame);
}
let comparedShell = 0;
for (const ref of trace) {
if (ref.fc !== 9 || ref.structs[1] !== 32) continue;
const var1 = ref.structs[12];
if (!var1) continue;
const frame = ref.structs[0] ? var1 / 8 - 1 : 16;
if (frame > 16) continue;
// Once removed, the following frame clears OAM while VAR1 stays 128.
if (!ref.structs[0] && !ref.oam[0]) continue;
eq(
ref.oam,
oam(shellSprites(assets.pokemon, frame)),
"shell OAM",
frame,
);
comparedShell++;
}
const final = trace.find((f) => f.fc === 9 && f.map[66] === 0);
if (!final) throw Error("No hatchling appeared");
const screen = hatchScreen(assets.pokemon, species, dvs, true);
eq(final.map, screen.map, "hatchling tilemap", 0);
eq(final.attrs, screen.attrs, "hatchling attrs", 0);
const rgb = (lo: number, hi: number) => {
const n = lo | (hi << 8);
return [
Math.round(((n & 31) * 255) / 31),
Math.round((((n >> 5) & 31) * 255) / 31),
Math.round((((n >> 10) & 31) * 255) / 31),
];
};
eq(
Array.from({ length: 4 }, (_, i) =>
rgb(final.bg[i * 2], final.bg[i * 2 + 1]),
),
screen.palettes[0],
"hatchling palette",
0,
);
eq(
Array.from({ length: 4 }, (_, i) =>
rgb(trace[start].bg[i * 2], trace[start].bg[i * 2 + 1]),
),
native[80].palette,
"egg palette",
0,
);
const originalObj = (final as unknown as { ob: number[] }).ob;
eq(
Array.from({ length: 8 }, (_, p) =>
Array.from({ length: 4 }, (_, i) =>
rgb(originalObj[p * 8 + i * 2], originalObj[p * 8 + i * 2 + 1]),
),
),
screen.objPalettes,
"object palettes",
0,
);
return { start, comparedWobble: 344, comparedShell, errors };
},
{ a, seq, species, dvs },
);
expect(result.comparedShell).toBeGreaterThanOrEqual(17);
expect(result.errors, JSON.stringify(result)).toEqual([]);
});