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 = {}; 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(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[] = []; 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([]); });