import { test, expect } from "@playwright/test"; import { existsSync, readFileSync } from "node:fs"; import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture"; test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols."); // BurnedTower1F's rival (map object 3) is OBJECTTYPE_TRAINER with sight 3, // but its script pointer is the generic ObjectEvent (home/map.asm's // `jumptextfaceplayer ObjectEventText`), not a TrainerHeader: see // maps/BurnedTower1F.asm's `object_event 8, 9, SPRITE_RIVAL, ..., 0, // OBJECTTYPE_TRAINER, 3, ObjectEvent, EVENT_RIVAL_BURNED_TOWER`. After // BurnedTowerRivalBattleScript the rival stands at (9,9) facing right and // the player at (10,9). _CheckTrainerBattle reads the "beaten" flag from // the script pointer's first two bytes: jumptextfaceplayer_command ($51) // and ObjectEventText's low byte ($f2), i.e. flag $f251. EventFlagAction's // bit math (de >> 3 added to wEventFlags) overflows by $1e4a bytes into // echo RAM, landing on wNumKeyItems (bank 1, $d8bc) bit 1. test("CheckTrainerBattle reads BurnedTower1F's rival as beaten only when wNumKeyItems' bit 1 is set", async ({ page }) => { const { at, routine } = loadSymbols(); const a = { romBank: at("hROMBank"), lastTalked: at("hLastTalked"), mapScriptsBank: at("wMapScriptsBank"), playerMapX: at("wPlayerMapX"), playerMapY: at("wPlayerMapY"), map3Object: at("wMap3Object"), object1Struct: at("wObject1Struct"), numKeyItems: at("wNumKeyItems"), seenTrainerDistance: at("wSeenTrainerDistance"), seenTrainerDirection: at("wSeenTrainerDirection"), tempTrainerEventFlag: at("wTempTrainerEventFlag"), tempTrainerClass: at("wTempTrainerClass"), tempTrainerID: at("wTempTrainerID"), seenTextPointer: at("wSeenTextPointer"), winTextPointer: at("wWinTextPointer"), lossTextPointer: at("wLossTextPointer"), scriptAfterPointer: at("wScriptAfterPointer"), runningTrainerBattleScript: at("wRunningTrainerBattleScript"), }; const checkTrainerBattle = routine("CheckTrainerBattle"); const objectEvent = at("ObjectEvent"); const mapScriptsBank = routine("BurnedTower1F_MapScripts").bank; await openReference(page); const result = await page.evaluate(async ({ a, checkTrainerBattle, objectEvent, mapScriptsBank }) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer()); const reference = new BinjgbEmulator(); await reference.load(rom, document.createElement("canvas")); const write = (p: number, v: number) => reference.writeMemory(p, v & 255); const ptr = (p: number, v: number) => { write(p, v); write(p + 1, v >> 8); }; const read = (p: number) => reference.readMemory(p); const readPtr = (p: number) => read(p) | (read(p + 1) << 8); // DI; SP=$c7fe; CheckTrainerBattle is home code, bank is irrelevant; // CALL CheckTrainerBattle; branch on carry into $c0f1, then the sentinel // at $c0f0, as the other reference fixtures do. Both live away from any // battle or trainer variable. const program = [ 0xf3, 0x31, 0xfe, 0xc7, 0x3e, checkTrainerBattle.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, 0xcd, checkTrainerBattle.address & 255, checkTrainerBattle.address >> 8, 0x38, 3, 0xaf, 0x18, 2, 0x3e, 1, 0xea, 0xf1, 0xc0, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe, ]; const results: unknown[] = []; for (let numKeyItems = 0; numKeyItems <= 6; numKeyItems++) { // The rival: a trainer with sight 3 whose script pointer is the // generic ObjectEvent, standing at (9,9) facing right; the player at // (10,9), one tile to the right. write(a.mapScriptsBank, mapScriptsBank); write(a.playerMapX, 10); write(a.playerMapY, 9); write(a.map3Object + 0, 1); // MAPOBJECT_OBJECT_STRUCT_ID -> wObject1Struct write(a.map3Object + 1, 1); // MAPOBJECT_SPRITE (nonzero: has a sprite) write(a.map3Object + 8, 2); // MAPOBJECT_TYPE: OBJECTTYPE_TRAINER write(a.map3Object + 9, 3); // MAPOBJECT_SIGHT_RANGE ptr(a.map3Object + 0xa, objectEvent); // MAPOBJECT_SCRIPT_POINTER -> ObjectEvent write(a.object1Struct + 0x10, 9); // OBJECT_MAP_X write(a.object1Struct + 0x11, 9); // OBJECT_MAP_Y write(a.object1Struct + 0x08, 0x0c); // OBJECT_DIRECTION: facing right (OW_RIGHT) write(a.numKeyItems, numKeyItems); // Sentinels: only a reported sighting overwrites these. write(a.lastTalked, 0xff); write(a.seenTrainerDistance, 0xff); write(a.seenTrainerDirection, 0xff); ptr(a.tempTrainerEventFlag, 0xffff); write(a.tempTrainerClass, 0xff); write(a.tempTrainerID, 0xff); ptr(a.seenTextPointer, 0xffff); ptr(a.winTextPointer, 0xffff); ptr(a.lossTextPointer, 0xffff); ptr(a.scriptAfterPointer, 0xffff); write(a.runningTrainerBattleScript, 0xff); write(0xc0f0, 0); write(0xc0f1, 0xff); 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 Error("CheckTrainerBattle did not return"); results.push({ numKeyItems, carry: read(0xc0f1) === 1, lastTalked: read(a.lastTalked), seenTrainerDistance: read(a.seenTrainerDistance), seenTrainerDirection: read(a.seenTrainerDirection), tempTrainer: { eventFlag: readPtr(a.tempTrainerEventFlag), class: read(a.tempTrainerClass), id: read(a.tempTrainerID), seenText: readPtr(a.seenTextPointer), winText: readPtr(a.winTextPointer), lossText: readPtr(a.lossTextPointer), scriptAfter: readPtr(a.scriptAfterPointer), runningTrainerBattleScript: read(a.runningTrainerBattleScript), }, }); } reference.dispose(); return results; }, { a, checkTrainerBattle, objectEvent, mapScriptsBank }); // Bit 1 of wNumKeyItems (2, 3, 6) makes the overflowed flag read set, so // the original treats the rival as already beaten and skips it; every // other count reports the sighting, copying ObjectEvent's bytes as a // (nonsensical) trainer header into wTempTrainer. const sentinelPtr = { eventFlag: 0xffff, class: 0xff, id: 0xff, seenText: 0xffff, winText: 0xffff, lossText: 0xffff, scriptAfter: 0xffff, runningTrainerBattleScript: 0xff }; const decodedHeader = { eventFlag: 0xf251, class: 0x26, id: 0x16, seenText: 0x46ee, winText: 0x5071, lossText: 0xfa53, scriptAfter: 0x1626, runningTrainerBattleScript: 0 }; const expected = [0, 1, 2, 3, 4, 5, 6].map((numKeyItems) => { const beaten = (numKeyItems & 2) !== 0; return { numKeyItems, carry: !beaten, lastTalked: beaten ? 0xff : 3, seenTrainerDistance: beaten ? 0xff : 1, seenTrainerDirection: beaten ? 0xff : 0x0c, tempTrainer: beaten ? sentinelPtr : decodedHeader, }; }); expect(result).toHaveLength(7); expect(result).toEqual(expected); }); // The battle CheckTrainerBattle reports when it does fire: LoadTrainer_continue // copies 13 bytes starting at ObjectEvent into wTempTrainer, reading its // command byte and ObjectEventText's pointer as a trainer class, ID and // text pointers. Class $26 is SWIMMERM (constants/trainer_constants.asm), // which has exactly 21 named trainers; ID $16 (22) is one past the last of // them. ReadTrainerParty's name-skipping loop (engine/battle/ // read_trainer_party.asm) scans forward from SwimmerMGroup's start for 21 // end-of-party ($ff) markers and, since the group tables in data/trainers/ // parties.asm sit back-to-back with no padding, lands exactly on // SwimmerFGroup's first entry: ELAINE, a single level 21 STARYU. The // party itself is no garbage: SWIMMERM's sprite, cry and class text with // SwimmerF's ELAINE as the opponent's name and sole Pokemon, confirmed here // by running the unmodified ReadTrainerParty and Battle_GetTrainerName // routines. StartBattleWithMapTrainerScript prints the seen text first, // though, and the next test shows that is not text. test("the decoded trainer header resolves to SwimmerF's ELAINE, one past SwimmerM's last trainer", async ({ page }) => { const { at, routine } = loadSymbols(); const a = { romBank: at("hROMBank"), inBattleTowerBattle: at("wInBattleTowerBattle"), linkMode: at("wLinkMode"), otherTrainerClass: at("wOtherTrainerClass"), otherTrainerID: at("wOtherTrainerID"), otPartyCount: at("wOTPartyCount"), otPartyMon1Species: at("wOTPartyMon1Species"), otPartyMon1Level: at("wOTPartyMon1Level"), stringBuffer1: at("wStringBuffer1"), }; const readTrainerParty = routine("ReadTrainerParty"); const getTrainerName = routine("Battle_GetTrainerName"); await openReference(page); const result = await page.evaluate(async ({ a, readTrainerParty, getTrainerName }) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer()); 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); write(a.inBattleTowerBattle, 0); write(a.linkMode, 0); write(a.otherTrainerClass, 0x26); // SWIMMERM write(a.otherTrainerID, 0x16); // 22: one past SWIMMERM's last trainer (21) // DI; SP=$c7fe; both routines share engine/battle/core.asm's bank; // CALL ReadTrainerParty (ends in a party of OT mons), then // CALL Battle_GetTrainerName (copies the name into wStringBuffer1). const program = [ 0xf3, 0x31, 0xfe, 0xc7, 0x3e, readTrainerParty.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, 0xcd, readTrainerParty.address & 255, readTrainerParty.address >> 8, 0xcd, getTrainerName.address & 255, getTrainerName.address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe, ]; 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 Error("ReadTrainerParty/Battle_GetTrainerName did not return"); const outcome = { partyCount: read(a.otPartyCount), species: read(a.otPartyMon1Species), level: read(a.otPartyMon1Level), name: Array.from({ length: 11 }, (_, i) => read(a.stringBuffer1 + i)), }; reference.dispose(); return outcome; }, { a, readTrainerParty, getTrainerName }); const STARYU = 0x78; // "ELAINE@" in the game's charmap, followed by SwimmerFGroup's next three // bytes (TRAINERTYPE_NORMAL, level 21, STARYU) and the party's $ff end // marker: the exact 11 (NAME_LENGTH) bytes CopyTrainerName copies. expect(result).toEqual({ partyCount: 1, species: STARYU, level: 21, name: [0x84, 0x8b, 0x80, 0x88, 0x8d, 0x84, 0x50, 0x00, 0x15, 0x78, 0xff], }); }); // StartBattleWithMapTrainerScript's `trainertext TRAINERTEXT_SEEN` prints // the decoded seen text ($46ee in the map scripts' bank) before the battle. // It points into the middle of SproutTower1FStatueText: its first byte is // the space character $7f, which DoTextUntilTerminator dispatches as a text // command. TextCommands has NUM_TEXT_CMDS (23) entries, so command $7f reads // its pointer from home code beyond the table and jumps into the middle of // the VBlank handlers. What happens next depends on the whole CPU state, so // the native port keeps skipping the sighting (see checkTrainers). test("the decoded seen text dispatches text command $7f, beyond TextCommands", () => { const { at, routine } = loadSymbols(); const rom = readFileSync(romPath); const bank = routine("BurnedTower1F_MapScripts").bank; const seenText = 0x46ee; const first = rom[bank * 0x4000 + seenText - 0x4000]; const NUM_TEXT_CMDS = 23; const entry = at("TextCommands") + first * 2; const target = rom[entry] | (rom[entry + 1] << 8); expect(first).toBe(0x7f); expect(first).toBeGreaterThanOrEqual(NUM_TEXT_CMDS); expect(entry).toBeGreaterThan(at("TextCommands") + NUM_TEXT_CMDS * 2); expect(target).toBe(0x0428); expect(target).toBeGreaterThan(at("VBlank_Credits")); expect(target).toBeLessThan(at("TextCommands")); });