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