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."); // TrainerClassAttributes AI_ITEM_SWITCH bits (constants/trainer_data_constants.asm). const ALWAYS_USE = 1 << 4, UNKNOWN_USE = 1 << 5, CONTEXT_USE = 1 << 6; /** * Compares the native AI_TryItem (engine/battle/ai/items.asm) against the * unmodified routine in the reference emulator, called directly (not * through AI_SwitchOrTryItem, whose switch-side dispatch predates this * port and is exercised by the existing switch tests/playthrough). Random * is redirected to a fixed byte stream shared by both runs, so the number * of draws is part of the comparison alongside the modeled state: the two item slots, the base reward (the misread-item bug), the * enemy's HP/status/substatuses, its stat levels, the turn-ending * substatus resets and wEnemyGoesFirst. * * The AI context/switch flags (CONTEXT_USE, ALWAYS_USE, UNKNOWN_USE) are * read live from TrainerClassAttributes by class, so eight reserved * classes (1-8) have that one byte patched into the ROM copy with every * combination; every other table this routine reads (AI_Items' item ids) * is exercised as native code, not re-read from ROM. */ test("AI_TryItem matches the original across items, flags, HP, status and turn state", async ({ page }) => { test.setTimeout(180000); const { at, routine } = loadSymbols(); const names = [ "hROMBank", "wInBattleTowerBattle", "wEnemyTrainerItem1", "wEnemyTrainerItem2", "wEnemyTrainerBaseReward", "wTrainerClass", "wOTPartyCount", "wOTPartyMon1Level", "wOTPartyMon1Status", "wOTPartyMon1HP", "wCurOTMon", "wEnemyMonStatus", "wEnemyMonHP", "wEnemyMonMaxHP", "wEnemySubStatus3", "wEnemySubStatus4", "wEnemySubStatus5", "wEnemyToxicCount", "wEnemyTurnsTaken", "wEnemyGoesFirst", "wEnemyFuryCutterCount", "wEnemyProtectCount", "wEnemyRageCounter", "wLastEnemyCounterMove", "wEnemyStatLevels", "wEnemyStats", "hBattleTurn", "wLinkMode", "wBattleMode", "wAttackMissed", "wEffectFailed", ]; const addresses = Object.fromEntries(names.map((name) => [name, at(name)])); const commands = { tryItem: routine("AI_TryItem"), random: routine("Random") }; const trainerClassAttributes = routine("TrainerClassAttributes"); // bank*0x4000 + address-0x4000: the ROM file offset (scripts/check-reference.mjs's formula). const flat = ({ bank, address }: { bank: number; address: number }) => address < 0x4000 ? address : bank * 0x4000 + address - 0x4000; const attributesFlat = flat(trainerClassAttributes); // A used item prints text and plays a sound, and the heal items animate // the HP bar and redraw the HUD; all four wait on VBlank-driven input or // timing that this isolated, interrupt-disabled call never provides, so // they are neutered like Random. None of their own effects (the screen, // audio and the HP bar's visual fill) are part of this comparison; the // WRAM state AI_TryItem changes before and around them is. const presentationFlats = ["PrintText", "PlaySFX", "AnimateHPBar", "UpdateEnemyHUD"].map((name) => flat(routine(name))); await openReference(page); const result = await page.evaluate( async ({ a, commands, attributesFlat, bits, presentationFlats }) => { 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 rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer()); const assets = new CrystalAssets(rom); // Patch Random (reference-smart-ai.spec.ts's stub and stream) and, for // eight reserved trainer classes (1-8), the AI_ITEM_SWITCH byte each // flag combination needs; AI_TryItem reads it live from ROM by class. const patched = rom.slice(); patched.set([0xc3, 0x80, 0xc0], commands.random.address); for (const p of presentationFlats) patched[p] = 0xc9; // ret const NUM_TRAINER_ATTRIBUTES = 7, TRNATTR_AI_ITEM_SWITCH = 5; bits.forEach((flags: number, i: number) => { const p = attributesFlat + i * NUM_TRAINER_ATTRIBUTES + TRNATTR_AI_ITEM_SWITCH; patched[p] = flags; patched[p + 1] = 0; }); const reference = new BinjgbEmulator(); await reference.load(patched, document.createElement("canvas")); const write = (p: number, v: number) => reference.writeMemory(p, v & 255); const word = (p: number, v: number) => { write(p, v >> 8); write(p + 1, v); }; const read = (p: number) => reference.readMemory(p); const readWord = (p: number) => (read(p) << 8) | read(p + 1); const stub = [0xe5, 0xd5, 0xfa, 0xe0, 0xc0, 0x5f, 0x16, 0, 0x21, 0, 0xc1, 0x19, 0x3c, 0xea, 0xe0, 0xc0, 0x7e, 0xd1, 0xe1, 0xc9]; stub.forEach((v, i) => write(0xc080 + i, v)); const { bank, address } = commands.tryItem; const program = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.hROMBank & 255, 0xea, 0, 0x20, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe]; let seed = 0x9a7c3; const next = (limit = 256) => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return (seed >>> 16) % limit; }; // Items in AI_Items' scan order, with 0 standing for NO_ITEM. const I = { FR: 0x0e, MP: 0x0f, HP: 0x10, SP: 0x11, PO: 0x12, XAC: 0x21, FH: 0x26, GS: 0x29, DH: 0x2c, XAT: 0x31, XDE: 0x33, XSP: 0x34, XSC: 0x35 }; const itemPairs: [number, number][] = [ [0, 0], [I.FR, 0], [I.MP, 0], [I.HP, I.SP], [I.PO, I.XAC], [I.FH, I.GS], [I.DH, I.XAT], [I.XDE, 0], [I.XSP, 0], [I.XSC, 0], [I.FH, I.HP], // last pair: the stuck-pointer scenario ]; const hpCases = [[100, 100], [30, 100], [10, 100], [25, 100], [26, 100], [50, 100], [51, 100], [25, 101]]; const statusCases: { status: number; toxic?: number }[] = [ { status: 0 }, { status: 1 << 5 /* FRZ */ }, { status: 3 /* asleep */ }, { status: 1 << 3 /* PSN */, toxic: 5 }, ]; const failures: unknown[] = []; let compared = 0, maxDraws = 0; for (let flagIndex = 0; flagIndex < bits.length; flagIndex++) for (const [item1, item2] of itemPairs) for (const [hp, maxHp] of hpCases) for (const { status, toxic } of statusCases) for (const turnsTaken of [0, 1]) { const classId = flagIndex + 1; const [attackMissed, effectFailed] = [[0, 0], [1, 0], [0, 1]][compared % 3]; const bytes = Array.from({ length: 64 }, () => next()); let draws = 0; const party = [{ level: 30, status: 0, hp: 50 }]; const trainer = { name: "", className: "", party: party as never, aiFlags: 0, switchFlags: bits[flagIndex], items: [item1, item2] as [number, number], baseReward: 25, reward: 0, }; const battle = new Battle({ data: assets.pokemon, party: [] as never, playerId: 1, playerName: "CHRIS", timeOfDay: 1, random: () => bytes[draws++], text: () => ({}) as never, pokedex: { seen: new Set(), caught: new Set() }, trainer, }); battle.turn = 1; battle.attackMissed = attackMissed; battle.effectFailed = effectFailed; battle.curOTMon = 0; battle.enemy.mon = { species: 19, item: 0, moves: [33, 0, 0, 0], pp: [35, 0, 0, 0], dvs: 0, happiness: 70, level: 30, status, hp, maxHp, stats: [30, 30, 30, 30, 30], types: [0, 0], nickname: "RATTATA", } as never; battle.enemy.levels = [7, 7, 7, 7, 7, 7, 7]; battle.enemy.sub = [0, 0, 0, (1 << 0) | (1 << 7) /* Bide, Confused */, 1 << 6 /* Rage */, toxic ? 1 : 0]; battle.enemy.toxicCount = toxic ?? 0; battle.enemy.turnsTaken = turnsTaken; battle.enemy.furyCutterCount = 3; battle.enemy.protectCount = 2; battle.enemy.rageCounter = 4; battle.enemy.lastCounterMove = 33; battle.enemyGoesFirst = 0; const internal = battle as unknown as { aiTryItem(): Generator }; const gen = internal.aiTryItem(); for (let step = gen.next(); !step.done; step = gen.next()); const nativeDraws = draws; write(a.wInBattleTowerBattle, 0); write(a.wTrainerClass, classId); write(a.wEnemyTrainerItem1, item1); write(a.wEnemyTrainerItem2, item2); write(a.wEnemyTrainerBaseReward, 25); write(a.wOTPartyCount, 1); write(a.wOTPartyMon1Level, 30); write(a.wOTPartyMon1Status, 0); word(a.wOTPartyMon1HP, 50); write(a.wCurOTMon, 0); write(a.wEnemyMonStatus, status); word(a.wEnemyMonHP, hp); word(a.wEnemyMonMaxHP, maxHp); write(a.wEnemySubStatus3, (1 << 0) | (1 << 7)); write(a.wEnemySubStatus4, 1 << 6); write(a.wEnemySubStatus5, toxic ? 1 : 0); write(a.wEnemyToxicCount, toxic ?? 0); write(a.wEnemyTurnsTaken, turnsTaken); write(a.wEnemyGoesFirst, 0); write(a.wEnemyFuryCutterCount, 3); write(a.wEnemyProtectCount, 2); write(a.wEnemyRageCounter, 4); write(a.wLastEnemyCounterMove, 33); for (let i = 0; i < 8; i++) write(a.wEnemyStatLevels + i, 7); for (let i = 0; i < 5; i++) word(a.wEnemyStats + i * 2, 30); write(a.hBattleTurn, 1); write(a.wLinkMode, 0); write(a.wBattleMode, 2); // RaiseStat (not part of this port) gates on these without // AI_TryItem ever clearing them; both runs start a turn // with the same stale flags; successful, missed and failed // previous moves all occur in this sweep. write(a.wAttackMissed, attackMissed); write(a.wEffectFailed, effectFailed); bytes.forEach((v, i) => write(0xc100 + i, v)); write(0xc0e0, 0); write(0xc0f0, 0); program.forEach((v, i) => write(0xc000 + i, v)); reference.setProgramCounter(0xc000); for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1); if (read(0xc0f0) !== 0x42) throw new Error("AI_TryItem did not return"); const original = { used: read(a.wEnemyGoesFirst) === 1, item1: read(a.wEnemyTrainerItem1), item2: read(a.wEnemyTrainerItem2), baseReward: read(a.wEnemyTrainerBaseReward), status: read(a.wEnemyMonStatus), hp: readWord(a.wEnemyMonHP), partyStatus: read(a.wOTPartyMon1Status), partyHP: readWord(a.wOTPartyMon1HP), sub3: read(a.wEnemySubStatus3), sub4: read(a.wEnemySubStatus4), sub5: read(a.wEnemySubStatus5), furyCutterCount: read(a.wEnemyFuryCutterCount), protectCount: read(a.wEnemyProtectCount), rageCounter: read(a.wEnemyRageCounter), lastCounterMove: read(a.wLastEnemyCounterMove), levels: Array.from({ length: 7 }, (_, i) => read(a.wEnemyStatLevels + i)), draws: read(0xc0e0), }; const native = { used: battle.enemyGoesFirst === 1, item1: trainer.items[0], item2: trainer.items[1], baseReward: trainer.baseReward, status: battle.enemy.mon.status, hp: battle.enemy.mon.hp, partyStatus: party[0].status, partyHP: party[0].hp, sub3: battle.enemy.sub[3], sub4: battle.enemy.sub[4], sub5: battle.enemy.sub[5], furyCutterCount: battle.enemy.furyCutterCount, protectCount: battle.enemy.protectCount, rageCounter: battle.enemy.rageCounter, lastCounterMove: battle.enemy.lastCounterMove, levels: battle.enemy.levels.slice(), draws: nativeDraws, }; maxDraws = Math.max(maxDraws, native.draws, original.draws); if (JSON.stringify(original) !== JSON.stringify(native)) failures.push({ flagIndex, item1, item2, hp, maxHp, status, toxic, turnsTaken, original, native }); compared++; } reference.dispose(); return { compared, maxDraws, failures, count: failures.length }; }, { a: addresses, commands, attributesFlat, presentationFlats, bits: [0, ALWAYS_USE, UNKNOWN_USE, CONTEXT_USE, ALWAYS_USE | UNKNOWN_USE, ALWAYS_USE | CONTEXT_USE, UNKNOWN_USE | CONTEXT_USE, ALWAYS_USE | UNKNOWN_USE | CONTEXT_USE] }, ); expect(result.compared).toBe(8 * 11 * 8 * 4 * 2); expect(result, JSON.stringify(result.failures.slice(0, 3))).toMatchObject({ count: 0, failures: [] }); }); /** * The base-reward-misread bug (docs/bugs_and_glitches.md "AI might use its * base reward value as an item"): no real trainer's base reward aliases an * AI_Items id (every two-item trainer's is 25), so this sets one up by * hand and checks both engines agree the AI "uses" Full Heal by zeroing * the reward rather than either real item slot. */ for (const entry of ["direct", "no-switch-style", "mean-look", "wrap"] as const) test(`Trainer item base-reward collision matches the original through ${entry}`, async ({ page }) => { const { at, routine } = loadSymbols(); const names = ["hROMBank", "wInBattleTowerBattle", "wEnemyTrainerItem1", "wEnemyTrainerItem2", "wEnemyTrainerBaseReward", "wTrainerClass", "wOTPartyCount", "wOTPartyMon1Level", "wOTPartyMon1Status", "wCurOTMon", "wEnemyMonStatus", "wEnemyMonHP", "wEnemyMonMaxHP", "wEnemySubStatus3", "wEnemySubStatus4", "wEnemySubStatus5", "wEnemyToxicCount", "wEnemyTurnsTaken", "wEnemyGoesFirst", "hBattleTurn", "wLinkMode", "wBattleMode", "wPlayerSubStatus5", "wEnemyWrapCount", "wEnemySubStatus1"]; const addresses = Object.fromEntries(names.map((name) => [name, at(name)])); const commands = { tryItem: routine(entry === "direct" ? "AI_TryItem" : "AI_SwitchOrTryItem"), random: routine("Random") }; const trainerClassAttributes = routine("TrainerClassAttributes"); const flat = ({ bank, address }: { bank: number; address: number }) => (address < 0x4000 ? address : bank * 0x4000 + address - 0x4000); const attributesFlat = flat(trainerClassAttributes); // See the main sweep above: these four are presentation-only and would // otherwise wait on input/timing this isolated call never provides. const presentationFlats = ["PrintText", "PlaySFX", "AnimateHPBar", "UpdateEnemyHUD"].map((name) => flat(routine(name))); await openReference(page); const result = await page.evaluate(async ({ a, commands, attributesFlat, presentationFlats, entry }) => { 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 rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer()); const assets = new CrystalAssets(rom); const patched = rom.slice(); patched.set([0xc3, 0x80, 0xc0], commands.random.address); for (const p of presentationFlats) patched[p] = 0xc9; // ret // Class 1's AI_ITEM_SWITCH byte: CONTEXT_USE. patched[attributesFlat + 5] = 0x40; patched[attributesFlat + 6] = 0; const reference = new BinjgbEmulator(); await reference.load(patched, document.createElement("canvas")); const write = (p: number, v: number) => reference.writeMemory(p, v & 255); const word = (p: number, v: number) => { write(p, v >> 8); write(p + 1, v); }; const read = (p: number) => reference.readMemory(p); const readWord = (p: number) => (read(p) << 8) | read(p + 1); const stub = [0xe5, 0xd5, 0xfa, 0xe0, 0xc0, 0x5f, 0x16, 0, 0x21, 0, 0xc1, 0x19, 0x3c, 0xea, 0xe0, 0xc0, 0x7e, 0xd1, 0xe1, 0xc9]; stub.forEach((v, i) => write(0xc080 + i, v)); const { bank, address } = commands.tryItem; const program = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.hROMBank & 255, 0xea, 0, 0x20, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe]; for (let i = 0; i < 16; i++) write(0xc100 + i, 0); // never drawn, but kept tidy const item1 = 0, item2 = 0x10 /* HYPER_POTION */, baseReward = 0x26; /* FULL_HEAL */ const trainer = { name: "", className: "", party: [{ level: 30, status: 0, hp: 50 }] as never, aiFlags: 0, switchFlags: 0x40, items: [item1, item2] as [number, number], baseReward, reward: 0 }; const battle = new Battle({ data: assets.pokemon, party: [] as never, playerId: 1, playerName: "CHRIS", timeOfDay: 1, random: () => 0, text: () => ({}) as never, pokedex: { seen: new Set(), caught: new Set() }, trainer, }); battle.turn = 1; battle.curOTMon = 0; battle.enemy.mon = { species: 19, item: 0, moves: [33, 0, 0, 0], pp: [35, 0, 0, 0], dvs: 0, happiness: 70, level: 30, status: 1 << 5, hp: 100, maxHp: 100, stats: [30, 30, 30, 30, 30], types: [0, 0], nickname: "RATTATA" } as never; battle.enemy.levels = [7, 7, 7, 7, 7, 7, 7]; battle.enemyGoesFirst = 0; if (entry === "mean-look") battle.player.set(5, 7); if (entry === "wrap") battle.enemy.wrapCount = 2; const internal = battle as unknown as { aiTryItem(): Generator; aiSwitchOrTryItem(): Generator; }; const gen = entry === "direct" ? internal.aiTryItem() : internal.aiSwitchOrTryItem(); for (let step = gen.next(); !step.done; step = gen.next()); write(a.wInBattleTowerBattle, 0); write(a.wTrainerClass, 1); write(a.wEnemyTrainerItem1, item1); write(a.wEnemyTrainerItem2, item2); write(a.wEnemyTrainerBaseReward, baseReward); write(a.wOTPartyCount, 1); write(a.wOTPartyMon1Level, 30); write(a.wOTPartyMon1Status, 0); write(a.wCurOTMon, 0); write(a.wEnemyMonStatus, 1 << 5); word(a.wEnemyMonHP, 100); word(a.wEnemyMonMaxHP, 100); write(a.wEnemySubStatus3, 0); write(a.wEnemySubStatus4, 0); write(a.wEnemySubStatus5, 0); write(a.wEnemyToxicCount, 0); write(a.wEnemyTurnsTaken, 0); write(a.wEnemyGoesFirst, 0); write(a.hBattleTurn, 1); write(a.wLinkMode, 0); write(a.wBattleMode, 2); write(a.wPlayerSubStatus5, entry === "mean-look" ? 0x80 : 0); write(a.wEnemyWrapCount, entry === "wrap" ? 2 : 0); write(a.wEnemySubStatus1, 0); program.forEach((v, i) => write(0xc000 + i, v)); write(0xc0f0, 0); reference.setProgramCounter(0xc000); for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1); if (read(0xc0f0) !== 0x42) throw new Error("AI_TryItem did not return"); const final = { original: { used: read(a.wEnemyGoesFirst) === 1, item1: read(a.wEnemyTrainerItem1), item2: read(a.wEnemyTrainerItem2), baseReward: read(a.wEnemyTrainerBaseReward), status: read(a.wEnemyMonStatus) }, native: { used: battle.enemyGoesFirst === 1, item1: trainer.items[0], item2: trainer.items[1], baseReward: trainer.baseReward, status: battle.enemy.mon.status }, }; reference.dispose(); return final; }, { a: addresses, commands, attributesFlat, presentationFlats, entry }); expect(result.native).toEqual(result.original); expect(result.native).toEqual({ used: true, item1: 0, item2: 0x10, baseReward: 0, status: 0 }); });