import { test, expect, type Page } from "@playwright/test"; import { existsSync } from "node:fs"; import { C } from "../src/game/battle/constants"; import { romPath, symbolsPath, loadSymbols, openReference, } from "./reference-fixture"; type Input = { turn: number; seeds: number[]; count: number; species: number; item: number; opponentItem: number; move: number; effect: number; power: number; accuracy: number; accuracyLevel: number; evasionLevel: number; userSub4: number; targetSub1: number; targetSub4: number; status: number; weather: number; damage: number; }; type Command = "critical" | "checkHit" | "damageVariation"; function inputs() { let seed = 0x91ce; const next = (limit: number) => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return (seed >>> 16) % limit; }; const make = (overrides: Partial = {}): Input => ({ turn: next(2), seeds: Array.from({ length: 10 }, () => next(256)), count: 0, species: 155, item: 0, opponentItem: 0, move: 33, effect: 0, power: 40, accuracy: 242, accuracyLevel: 7, evasionLevel: 7, userSub4: 0, targetSub1: 0, targetSub4: 0, status: 0, weather: 0, damage: 100, ...overrides, }); return { next, make }; } /** Execute unpatched battle commands with the original Link RNG. The native * callback consumes the same ten-byte stream; compare its final state too, * so skipped draws, rejection sampling and stream regeneration are checked. */ async function compare(page: Page, command: Command, cases: Input[]) { const { at, routine } = loadSymbols(); const addresses = { command: routine( { critical: "BattleCommand_Critical", checkHit: "BattleCommand_CheckHit", damageVariation: "BattleCommand_DamageVariation", }[command], ), romBank: at("hROMBank"), turn: at("hBattleTurn"), link: at("wLinkMode"), seeds: at("wLinkBattleRNs"), count: at("wLinkBattleRNCount"), damage: at("wCurDamage"), critical: at("wCriticalHit"), missed: at("wAttackMissed"), weather: at("wBattleWeather"), sides: [ { mon: at("wBattleMonSpecies"), status: at("wBattleMonStatus"), sub: at("wPlayerSubStatus1"), move: at("wPlayerMoveStruct"), accuracy: at("wPlayerAccLevel"), evasion: at("wPlayerEvaLevel"), }, { mon: at("wEnemyMonSpecies"), status: at("wEnemyMonStatus"), sub: at("wEnemySubStatus1"), move: at("wEnemyMoveStruct"), accuracy: at("wEnemyAccLevel"), evasion: at("wEnemyEvaLevel"), }, ], }; await openReference(page); const results = await page.evaluate( async ({ addresses, command, cases }) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const { CrystalAssets } = await import("/src/game/rom/assets.ts"); const { Battle } = await import("/src/game/battle/battle.ts"); const file = ( document.querySelector("#reference-fixture") as HTMLInputElement ).files![0]; const rom = new Uint8Array(await file.arrayBuffer()); const assets = new CrystalAssets(rom); const reference = new BinjgbEmulator(); await reference.load(rom, document.createElement("canvas")); const write = (address: number, value: number) => reference.writeMemory(address, value & 255); const word = (address: number, value: number) => { write(address, value >> 8); write(address + 1, value); }; const readWord = (address: number) => (reference.readMemory(address) << 8) | reference.readMemory(address + 1); const target = addresses.command; // DI; SP=$c7fe; select ROM bank; CALL command; sentinel; loop. const program = [ 0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, addresses.romBank & 255, 0xea, 0x00, 0x20, 0xcd, target.address & 255, target.address >> 8, 0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe, ]; write(0xff70, 1); write(addresses.link, 1); const results = cases.map((input, index) => { let seeds = [...input.seeds], count = input.count, draws = 0; const battle = new Battle({ data: assets.pokemon, party: [], pokedex: { seen: new Set(), caught: new Set() }, playerId: 1, playerName: "CHRIS", timeOfDay: 1, text: () => ({}) as never, random: () => { if (++draws > 1024) throw new Error(`Unbounded RNG in case ${index}`); const value = seeds[count++]; if (count >= 9) { count = 0; seeds = seeds.map((value) => (value * 5 + 1) & 255); } return value; }, }); // Test the production commands directly, without starting a battle // (which would consume encounter, AI and turn-order draws first). const internal = battle as unknown as { turn: number; criticalHit: number; attackMissed: number; curDamage: number; weather: number; critical(): void; damageVariation(): void; checkHit(): Generator; }; internal.turn = input.turn; internal.criticalHit = 0xff; internal.attackMissed = 0; internal.curDamage = input.damage; internal.weather = input.weather; write(addresses.turn, input.turn); write(addresses.critical, 0xff); write(addresses.missed, 0); word(addresses.damage, input.damage); write(addresses.weather, input.weather); write(addresses.count, input.count); input.seeds.forEach((value, i) => write(addresses.seeds + i, value)); [battle.player, battle.enemy].forEach((side, i) => { const user = i === input.turn; const address = addresses.sides[i]; side.mon = { species: user ? input.species : 16, item: user ? input.item : input.opponentItem, status: user ? 0 : input.status, } as never; side.move = { anim: input.move, effect: input.effect, power: input.power, accuracy: input.accuracy, type: 0, pp: 35, chance: 0, }; side.sub = [ 0, user ? 0 : input.targetSub1, 0, 0, user ? input.userSub4 : input.targetSub4, 0, ]; side.levels = [ 7, 7, 7, 7, 7, user ? input.accuracyLevel : 7, user ? 7 : input.evasionLevel, ]; write(address.mon, side.mon.species); write(address.mon + 1, side.mon.item); write(address.status, side.mon.status); side.sub .slice(1) .forEach((value, j) => write(address.sub + j, value)); [ input.move, input.effect, input.power, 0, input.accuracy, 35, 0, ].forEach((value, j) => write(address.move + j, value)); write(address.accuracy, side.levels[5]); write(address.evasion, side.levels[6]); }); if (command === "checkHit") { if (!internal.checkHit().next().done) throw new Error("Unexpected text in CheckHit fixture"); } else internal[command](); write(0xc6ff, 0); program.forEach((value, i) => write(0xc000 + i, value)); reference.setProgramCounter(0xc000); reference.advanceFrames(2); if (reference.readMemory(0xc6ff) !== 0x42) throw new Error(`${command} case ${index} did not return`); return { native: { critical: internal.criticalHit, missed: internal.attackMissed, damage: internal.curDamage, accuracy: (input.turn ? battle.enemy : battle.player).move.accuracy, count, seeds, }, original: { critical: reference.readMemory(addresses.critical), missed: reference.readMemory(addresses.missed), damage: readWord(addresses.damage), accuracy: reference.readMemory( addresses.sides[input.turn].move + 4, ), count: reference.readMemory(addresses.count), seeds: Array.from({ length: 10 }, (_, i) => reference.readMemory(addresses.seeds + i), ), }, draws, }; }); reference.dispose(); return results; }, { addresses, command, cases }, ); results.forEach((result, index) => { expect( result.native, `${command} case ${index}: ${JSON.stringify(cases[index])}`, ).toEqual(result.original); }); return results; } test.beforeEach(() => { test.skip( !existsSync(romPath) || !existsSync(symbolsPath), "Requires the local user ROM and the private PRET rebuild.", ); }); test("critical hits match original thresholds, held items and random consumption", async ({ page, }) => { const { make } = inputs(); const cases: Input[] = []; // Every random byte at every reachable critical stage; special held items // bypass Focus Energy and the high-critical move bonus in the original. const variants = [ {}, { userSub4: 1 << C.SUBSTATUS_FOCUS_ENERGY }, { move: 163 }, // Slash { move: 163, item: 0x8c }, // Scope Lens { move: 163, item: 0x8c, userSub4: 1 << C.SUBSTATUS_FOCUS_ENERGY }, { species: C.CHANSEY, item: C.LUCKY_PUNCH, move: 163, userSub4: 1 << C.SUBSTATUS_FOCUS_ENERGY, }, { species: C.FARFETCH_D, item: C.STICK, move: 163, userSub4: 1 << C.SUBSTATUS_FOCUS_ENERGY, }, { species: 155, item: C.LUCKY_PUNCH }, { species: 155, item: C.STICK }, { power: 0, move: 163, item: 0x8c }, ]; for (const variant of variants) for (let roll = 0; roll < 256; roll++) { const input = make({ ...variant, turn: roll % 2, count: roll % 3 === 0 ? 8 : 0, }); input.seeds[input.count] = roll; cases.push(input); } const results = await compare(page, "critical", cases); results.forEach((result, i) => expect(result.draws).toBe(cases[i].power ? 1 : 0), ); }); test("damage variation matches original rerolls, stream regeneration and rounding", async ({ page, }) => { const { make, next } = inputs(); const cases: Input[] = []; for (const damage of [0, 1, 2, 3, 255, 256, 997, 65535]) { for (let roll = 0; roll < 256; roll++) { const input = make({ damage, count: roll % 3 === 0 ? 8 : 0 }); input.seeds[input.count] = roll; cases.push(input); } } // Nine rejected zeros force regeneration before the first accepted value. cases.push(make({ damage: 100, seeds: Array(10).fill(0) })); for (let i = 0; i < 100; i++) cases.push(make({ damage: next(65536), count: next(10) })); const results = await compare(page, "damageVariation", cases); expect(Math.max(...results.map((result) => result.draws))).toBeGreaterThan(9); results.forEach((result, i) => { if (cases[i].damage < 2) expect(result.draws).toBe(0); }); }); test("accuracy matches original stages, bypasses, miss damage and random consumption", async ({ page, }) => { const { make, next } = inputs(); const cases: Input[] = []; // Exhaust all stage pairs, including the Foresight branch and BrightPowder. for (let accuracyLevel = 1; accuracyLevel <= 13; accuracyLevel++) { for (let evasionLevel = 1; evasionLevel <= 13; evasionLevel++) { for (const identified of [false, true]) cases.push( make({ accuracyLevel, evasionLevel, accuracy: [0, 1, 76, 178, 242, 255][next(6)], opponentItem: next(2) ? 3 : 0, targetSub1: identified ? 1 << C.SUBSTATUS_IDENTIFIED : 0, }), ); } } // The full byte range catches < versus <= at hit thresholds, including // perfect accuracy's no-draw path and BrightPowder's floor at zero. for (const accuracy of [0, 1, 20, 242, 255]) for (const opponentItem of [0, 3]) { for (let roll = 0; roll < 256; roll++) { const input = make({ accuracy, opponentItem, turn: roll % 2, count: 8, }); input.seeds[8] = roll; cases.push(input); } } const bypasses = [ { effect: C.EFFECT_ALWAYS_HIT }, { effect: C.EFFECT_THUNDER, weather: C.WEATHER_RAIN }, { userSub4: 1 << C.SUBSTATUS_X_ACCURACY }, { effect: C.EFFECT_DREAM_EATER, status: 0 }, { effect: C.EFFECT_DREAM_EATER, status: 2, targetSub4: 1 << C.SUBSTATUS_SUBSTITUTE, }, { effect: C.EFFECT_LEECH_HIT, targetSub4: 1 << C.SUBSTATUS_SUBSTITUTE }, ]; for (const variant of bypasses) for (const turn of [0, 1]) cases.push( make({ ...variant, turn, accuracy: 1, accuracyLevel: 1, evasionLevel: 13, opponentItem: 3, }), ); for (const turn of [0, 1]) { cases.push( make({ turn, effect: C.EFFECT_JUMP_KICK, accuracy: 1, seeds: Array(10).fill(255), }), ); cases.push(make({ turn, effect: C.EFFECT_DREAM_EATER, status: 2 })); } const results = await compare(page, "checkHit", cases); const bypassStart = cases.length - bypasses.length * 2 - 4; results .slice(bypassStart, -4) .forEach((result) => expect(result.draws).toBe(0)); });