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:
1 parent
7d03f5c5ca
commit
e49bc6de9a
49 files changed
+6028
-88
No files matched your search
@@ -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([]);
|
||||
});
|
||||
Reference in new issue
Block a user