import { test, expect } from "@playwright/test"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { BattleData } from "../src/game/battle/data"; import { applyTypeMatchups, calculateDamage } from "../src/game/battle/battle"; import { C } from "../src/game/battle/constants"; import { resolve } from "node:path"; const romPath = resolve( process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc", ); test("native decompression matches original GB assembly executing in the reference emulator", async ({ page, }) => { test.skip(!existsSync(romPath), "Requires the local user ROM."); await page.goto("/reference.html"); // File chooser supplies bytes to an isolated test input, without an HTTP ROM endpoint. await page.evaluate(() => { const input = document.createElement("input"); input.type = "file"; input.id = "reference-fixture"; document.body.append(input); }); await page.locator("#reference-fixture").setInputFiles(romPath); const result = await page.evaluate(async () => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const { CrystalAssets } = await import("/src/game/rom/assets.ts"); const { decompress } = await import("/src/game/rom/lz.ts"); const { SYMBOLS, NATIVE_MAPS } = await import("/src/game/rom/profile.ts"); const file = ( document.querySelector("#reference-fixture") as HTMLInputElement ).files![0]; const bytes = new Uint8Array(await file.arrayBuffer()); const canvas = document.createElement("canvas"); const reference = new BinjgbEmulator(); const assets = new CrystalAssets(bytes); await reference.load(bytes, canvas); const comparisons: { map: string; bytes: number; equal: boolean; returned: boolean; }[] = []; for (const id of Object.keys(NATIVE_MAPS)) { const map = assets.map(id); const pointer = assets.reader.far(SYMBOLS.tilesets + map.tileset * 15); const bank = Math.floor(pointer / 0x4000), address = 0x4000 + (pointer % 0x4000); const native = decompress(assets.reader, pointer); // Original test trampoline in WRAM: DI; SP=$c7fe; A=bank; // HL=compressed ROM address; DE=$d000; CALL FarDecompress ($0b40); // write a sentinel and loop. The referenced routine is the user's ROM. const program = [ 0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0x21, address & 255, address >> 8, 0x11, 0, 0xd0, 0xcd, 0x40, 0x0b, 0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe, ]; reference.writeMemory(0xff70, 6); reference.writeMemory(0xff9d, 1); reference.writeMemory(0xc6ff, 0); program.forEach((value, index) => reference.writeMemory(0xc000 + index, value), ); reference.setProgramCounter(0xc000); reference.advanceFrames(30); comparisons.push({ map: id, bytes: native.length, returned: reference.readMemory(0xc6ff) === 0x42, equal: native.every( (value, index) => reference.readMemory(0xd000 + index) === value, ), }); } reference.dispose(); return comparisons; }); expect(result.length).toBe(6); for (const comparison of result) { expect( comparison.returned, `${comparison.map}: original routine returned`, ).toBe(true); expect( comparison.equal, `${comparison.map}: ${comparison.bytes} decompressed bytes match`, ).toBe(true); } }); test("native damage and type effectiveness match the original battle routines", async ({ page, }) => { const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym"; test.skip( !existsSync(romPath) || !existsSync(symbolsPath), "Requires the local user ROM and the private PRET rebuild.", ); const symbols = new Map( readFileSync(symbolsPath, "utf8") .split("\n") .flatMap((line) => { const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line); return match ? [[match[3], { bank: parseInt(match[1], 16), address: parseInt(match[2], 16) }] as const] : []; }), ); const at = (name: string) => symbols.get(name)!.address; const routine = (name: string) => symbols.get(name)!; const bytes = new Uint8Array(readFileSync(romPath)); const assets = new CrystalAssets(bytes); const data = new BattleData(assets.reader); const matchups = data.typeMatchups(); // A deterministic generator keeps failures reproducible. let seed = 0x1234; const next = (limit: number) => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 8) % limit; }; const types = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 24, 25, 26, 27]; const effects = [0, 0, 0, C.EFFECT_SELFDESTRUCT, C.EFFECT_MULTI_HIT, C.EFFECT_CONVERSION]; const damageCases = Array.from({ length: 400 }, () => { const item = next(4) === 0 ? next(191) : 0; return { power: next(8) === 0 ? 0 : next(256), level: 1 + next(100), attack: next(256), defense: next(256), effect: effects[next(effects.length)], critical: next(2), current: next(5) === 0 ? next(1200) : 0, type: types[next(types.length)], item, }; }); const stabCases = Array.from({ length: 400 }, () => ({ move: 1 + next(164), type: types[next(types.length)], user: [types[next(types.length)], types[next(types.length)]], target: [types[next(types.length)], types[next(types.length)]], identified: next(4) === 0, damage: next(1000), })); await page.goto("/reference.html"); await page.evaluate(() => { const input = document.createElement("input"); input.type = "file"; input.id = "reference-fixture"; document.body.append(input); }); await page.locator("#reference-fixture").setInputFiles(romPath); const addresses = { curDamage: at("wCurDamage"), critical: at("wCriticalHit"), turn: at("hBattleTurn"), playerMove: at("wPlayerMoveStruct"), battleItem: at("wBattleMonItem"), battleTypes: at("wBattleMonType1"), enemyTypes: at("wEnemyMonType1"), typeModifier: at("wTypeModifier"), attackMissed: at("wAttackMissed"), weather: at("wBattleWeather"), johto: at("wJohtoBadges"), kanto: at("wKantoBadges"), link: at("wLinkMode"), tower: at("wInBattleTowerBattle"), enemySub1: at("wEnemySubStatus1"), damageCalc: routine("BattleCommand_DamageCalc"), stab: routine("BattleCommand_Stab"), }; const results = await page.evaluate( async ({ addresses, damageCases, stabCases }) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0]; const reference = new BinjgbEmulator(); await reference.load(new Uint8Array(await file.arrayBuffer()), document.createElement("canvas")); const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff); const word = (address: number, value: number) => { write(address, value >> 8); write(address + 1, value); }; // Trampoline: DI; SP=$c7fe; switch to the routine's bank; set B, C, D, E; // CALL the original routine; write a sentinel; loop. const call = (target: { bank: number; address: number }, registers: number[]) => { const program = [ 0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20, 0x06, registers[0], 0x0e, registers[1], 0x16, registers[2], 0x1e, registers[3], 0xcd, target.address & 0xff, target.address >> 8, 0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe, ]; write(0xc6ff, 0); program.forEach((value, index) => write(0xc000 + index, value)); reference.setProgramCounter(0xc000); reference.advanceFrames(2); return reference.readMemory(0xc6ff) === 0x42; }; write(0xff70, 1); // WRAM bank 1 holds wCurDamage and wEnemyMon for (const address of [addresses.weather, addresses.johto, addresses.kanto, addresses.link, addresses.tower]) write(address, 0); write(addresses.turn, 0); const damage = damageCases.map((input) => { write(addresses.playerMove + 1, input.effect); write(addresses.playerMove + 3, input.type); write(addresses.battleItem, input.item); write(addresses.critical, input.critical); word(addresses.curDamage, input.current); // B = attack, C = defense, D = power, E = level const returned = call(addresses.damageCalc, [input.attack, input.defense, input.power, input.level]); return { returned, damage: (reference.readMemory(addresses.curDamage) << 8) | reference.readMemory(addresses.curDamage + 1), }; }); const stab = stabCases.map((input) => { write(addresses.playerMove, input.move); write(addresses.playerMove + 3, input.type); write(addresses.battleTypes, input.user[0]); write(addresses.battleTypes + 1, input.user[1]); write(addresses.enemyTypes, input.target[0]); write(addresses.enemyTypes + 1, input.target[1]); write(addresses.enemySub1, input.identified ? 1 << 3 : 0); write(addresses.typeModifier, 10); write(addresses.attackMissed, 0); word(addresses.curDamage, input.damage); const returned = call(addresses.stab, [0, 0, 0, 0]); return { returned, damage: (reference.readMemory(addresses.curDamage) << 8) | reference.readMemory(addresses.curDamage + 1), modifier: reference.readMemory(addresses.typeModifier), missed: reference.readMemory(addresses.attackMissed), }; }); reference.dispose(); return { damage, stab }; }, { addresses, damageCases, stabCases }, ); damageCases.forEach((input, index) => { const original = results.damage[index]; expect(original.returned, `damage case ${index} returned`).toBe(true); const held = data.heldEffect(input.item); const boost = held.effect && data.typeBoost(held.effect) === input.type ? held.parameter : undefined; const native = calculateDamage({ power: input.power, level: input.level, attack: input.attack, defense: input.defense, effect: input.effect, critical: Boolean(input.critical), boost, current: input.current, }) ?? input.current; expect(native, `damage case ${index}: ${JSON.stringify(input)}`).toBe(original.damage); }); stabCases.forEach((input, index) => { const original = results.stab[index]; expect(original.returned, `type case ${index} returned`).toBe(true); const native = applyTypeMatchups(matchups, { damage: input.damage, modifier: 10, moveType: input.type, userTypes: input.user, targetTypes: input.target, identified: input.identified, }); expect( [native.damage, native.modifier, Number(native.missed)], `type case ${index}: ${JSON.stringify(input)}`, ).toEqual([original.damage, original.modifier, original.missed]); }); });