From 00972bad103f3b09b4e6341d822fb953d1b423ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20QUEROL?= Date: Wed, 30 Sep 2026 15:48:00 +0200 Subject: [PATCH] Verify random battle and capture routines against the original engine --- docs/NATIVE_PORT.md | 44 +++- docs/PLAN.md | 5 +- docs/SESSION_HANDOFF.md | 82 +++++- src/game/battle/battle.ts | 66 +++-- tests/capture.test.ts | 4 +- tests/reference-capture.spec.ts | 312 ++++++++++++++++++++++ tests/reference-fixture.ts | 48 ++++ tests/reference-parity.spec.ts | 41 +-- tests/reference-random.spec.ts | 450 ++++++++++++++++++++++++++++++++ 9 files changed, 978 insertions(+), 74 deletions(-) create mode 100644 tests/reference-capture.spec.ts create mode 100644 tests/reference-fixture.ts create mode 100644 tests/reference-random.spec.ts diff --git a/docs/NATIVE_PORT.md b/docs/NATIVE_PORT.md index bb07c38..9d127f3 100644 --- a/docs/NATIVE_PORT.md +++ b/docs/NATIVE_PORT.md @@ -48,6 +48,26 @@ The opening scenes currently run end to end from the ROM: Mom's Pokégear, weekd The same suite compares 400 damage cases and 400 type-effectiveness cases per browser against the original battle routines, 300 `CalcMonStats` cases (with and without stat experience, including levels above 100 that reach the 999 cap) and every `CalcExpAtLevel` value for the six growth rates and levels 1–100. Battle unit tests cover win, escape, loss, experience, level-up and move-learning flows, and `tests/playthrough.test.ts` plays the whole opening quest from a new game through `GameWorld`'s inputs, with save/reload after each stage. This establishes parity only for the compared routines and inputs. It does not prove complete gameplay, rendering, battles, audio, timing or physical-device parity. Extend controlled comparisons as each subsystem is translated. +`tests/reference-random.spec.ts` also executes the unpatched +`BattleCommand_Critical`, `BattleCommand_CheckHit` and +`BattleCommand_DamageVariation` routines. The original Link RNG and the native +random callback start from the same ten-byte stream. Each case compares the +result, modified accuracy, miss damage and final RNG stream/cursor, covering +skipped draws and rejection sampling across stream regeneration. Each browser +runs 2,560 critical cases, 2,914 accuracy cases and 2,149 damage-variation cases. +Critical coverage includes every random byte at all five reachable stages, +Focus Energy, Scope Lens and the species-specific items. Accuracy coverage +includes all stage pairs, Foresight, BrightPowder, perfect accuracy, X Accuracy, +rain/Thunder, Dream Eater, drain/substitute failure and Jump Kick miss damage. +Damage coverage includes 0/1's no-draw path, rounding and 16-bit boundaries. +Mutating each command's threshold or divisor makes its comparison fail. + +These are isolated command comparisons using controlled Link RNG inputs, not +a claim of Link battle support or matching the hardware-timed non-Link RNG. +Protect's text path and the refused Lock-On/Fly/Dig paths are outside these +fixtures. Capture decision comparisons are described below; whole-battle +reference comparisons remain open. + ## Validation ```sh @@ -153,8 +173,28 @@ division by zero. Pure vectors cover the catch formula; local-ROM tests cover capture, every break-free message, Master Ball draws, blank nicknames, the blocked trainer ball, full-party rollback, both tutorial triggers, declining and the repeat offer, and mid-battle reloads. Browser -tests cover PACK, catching and naming. No emulator comparison of the -capture routines has been run. +tests cover PACK, catching and naming. + +`tests/reference-capture.spec.ts` compares the production capture decision +and shake loop with `PokeBallEffect`'s calculation and the original +`GetPokeBallWobble`. Each browser runs 3,220 capture cases and 7,424 shake +cases: every catch-rate byte and supported multiplier, HP scaling and +wrapping, status bonuses, success-draw equality, every random byte at each +wobble-table entry, early/later escapes, and the Master Ball/tutorial +bypasses. Final rates, outcomes, shake counts and random-draw counts match. +The native decision and shake loop are private helpers called by the actual +ball-use path; existing capture integration tests still exercise that path. + +Unlike the battle commands, capture calls hardware `Random`. This fixture +patches its entry in the private in-page ROM copy to consume supplied bytes, +enters after the capture UI setup, and inserts a return after the decision. +The catch arithmetic, ball multipliers and wobble routine are unmodified. +It does not compare hardware RNG timing, animation, caught-mon reloading, +party/PC writes, or a whole battle. No real held item has `HELD_CATCH_CHANCE`, +so that synthetic effect remains covered by pure vectors only. Maximum HP +342 and 683 are omitted from emulator execution because their divisor is +zero; synthetic tests verify the native refusal. Deliberate mutations of +success equality, formula rounding and shake equality fail the comparisons. ## Switching, battle items and the stats screen diff --git a/docs/PLAN.md b/docs/PLAN.md index 2654db8..6ff7c2f 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -235,14 +235,15 @@ their scripts and story progression are supported. - [x] Player switching and every non-ball battle item, with the stats screen - [x] The PC and boxes (catching with a full party) - [x] The introduction's naming and clock screens -- [ ] Controlled-seed comparisons with the original for random-driven routines (accuracy, criticals, damage variation, capture). Damage, types, stats and experience curves are compared. +- [x] Controlled-seed comparisons with the original for accuracy, criticals and damage variation, including random-stream consumption. Damage, types, stats and experience curves are also compared. +- [x] Controlled-input comparisons of capture odds, success and ball shakes with the original, including random-draw counts. Hardware RNG timing and the full capture sequence remain outside this comparison. - [x] Acceptance: one continuous playthrough of the opening quest with save/reload (`tests/playthrough.test.ts`, driven through the world's inputs rather than the browser UI) Introduce native player, party, Pokémon, inventory, money and quest models. Read species, moves, growth curves, evolutions, encounters, trainers and items through typed ROM decoders. Define serialization/migrations before persisting new systems. Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game. -Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. Next: controlled-seed comparisons of the random-driven battle routines. +Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. Next: party and bag menus outside battle, MOVE PKMN W/O MAIL and evolution. Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior. diff --git a/docs/SESSION_HANDOFF.md b/docs/SESSION_HANDOFF.md index 8d23539..9912400 100644 --- a/docs/SESSION_HANDOFF.md +++ b/docs/SESSION_HANDOFF.md @@ -1,6 +1,6 @@ # Recovered Claude session -Latest continuation: see **Battle items, hBattleTurn and the stats screen: 2026-09-30** +Latest continuation: see **Controlled capture comparisons: 2026-09-30** at the end of this file. Earlier sections are historical snapshots. Recovered on 2026-09-23 from the local Claude Code transcript @@ -607,3 +607,83 @@ without it); 97 Chromium/WebKit browser tests passed, 19 skipped intentionally; native and reference builds; reference, release and vendor checks; `git diff --check`. Committed and pushed to `main` as `b13e09e`. + +## Controlled random battle comparisons: 2026-09-30 + +Resumed from `be7575a` with a clean working tree. The user requested a bounded +increment suitable for a limited session. Continued PLAN section 2 with +accuracy, criticals and damage variation; capture stays as the next item. + +- `tests/reference-random.spec.ts` runs the original, unpatched + `BattleCommand_Critical`, `BattleCommand_CheckHit` and + `BattleCommand_DamageVariation` in the reference emulator. The native + commands receive the same ten-byte Link RNG stream. Results include the + final RNG stream/cursor, so extra or missing draws are detected too. +- Per browser: 2,560 critical cases (all random bytes at all five reachable + stages and held-item exceptions), 2,914 accuracy cases (all stage pairs, + Foresight, BrightPowder, bypasses, Dream Eater/substitute and Jump Kick + miss damage), and 2,149 variation cases (0/1, rounding, 16-bit boundaries, + rejected draws and RNG stream regeneration). +- Shared ROM input and symbol helpers moved from `reference-parity.spec.ts` + into `tests/reference-fixture.ts`. No ROM bytes or extracted assets added. +- Deliberately changing critical `<` to `<=`, accuracy `>=` to `>`, and the + damage divisor from 255 to 256 each made its comparison fail. All three + mutations were reverted; no production gameplay change was needed. + +Boundary: these compare isolated commands with controlled random inputs. +They do not compare a whole battle, the hardware-timed non-Link RNG, or the +Protect text path. Lock-On/Fly/Dig remain refused. This does not enable Link +battles or later maps. + +Checks: 337 unit tests passed with the local ROM; 131 passed and 206 +skipped with an absent ROM. All 103 Chromium/WebKit browser tests passed, +with the existing 19 intentional WebKit skips. Native and reference builds, +reference, vendor and release checks, and `git diff --check` passed. No CI +or physical-device run. This increment was initially left uncommitted and +is included in the subsequent capture-comparison delivery below. + +Next: controlled-input capture comparisons (`PokeBallEffect` and +`GetPokeBallWobble`), then the party/bag menus outside battle, MOVE PKMN W/O +MAIL and evolution. Keep section 2's capture checklist open until verified. + + +## Controlled capture comparisons: 2026-09-30 + +Continued PLAN section 2 from the existing uncommitted accuracy, critical and +damage-variation comparisons. Preserved that work and completed its next +capture-comparison item. + +- `tests/reference-capture.spec.ts` compares the native capture decision with + the calculation inside `PokeBallEffect`, then its shake loop with + `GetPokeBallWobble`. Per browser: 3,220 capture cases and 7,424 shake cases, + including supported ball multipliers, all catch-rate bytes, HP scaling and + overflow, status bonuses, equality boundaries, every random byte at each + wobble-table entry, every escape count and guaranteed catches. Final rate, + caught/escaped outcome, shake count and random-draw count agree. +- Capture uses hardware `Random`, not `BattleRandom`. The private in-page ROM + copy replaces `Random` with a supplied-byte reader; the fixture enters + after the UI setup and inserts a return after `wWildMon` is written. + Catch arithmetic, multipliers and `GetPokeBallWobble` remain unmodified. +- Extracted `rollCapture` and `ballWobbles` as private production helpers + called by `pokeBallEffect`, without changing gameplay behavior. Added + maximum HP 683 beside 342 in the synthetic divide-by-zero regression. +- Deliberately changing success `<=` to `<`, formula floor to ceiling, and + shake `>=` to `>` each failed the comparisons. Mutations were reverted. + +Boundary: isolated capture decision/shakes with supplied random bytes, not +hardware RNG timing, animation, caught-mon reloading, party/PC writes or a +whole battle. No actual item has `HELD_CATCH_CHANCE`; that effect remains +covered by pure vectors. Native division-by-zero refusal remains in place; +the emulator fixture omits the hanging HP inputs. No additional balls or +maps are enabled. + +Checks: 337 unit tests passed with the local ROM; 131 passed and 206 skipped +with an absent ROM. All 107 Chromium/WebKit browser tests passed, with the +existing 19 intentional WebKit skips. The four new capture tests skip cleanly +without a ROM. Native and reference builds, reference, vendor and release +checks, and `git diff --check` passed. No CI or physical-device run. +The accuracy, critical, damage-variation and capture comparisons are delivered +together, preserving the previously uncommitted work. + +Next: party and bag menus outside battle, MOVE PKMN W/O MAIL and evolution. +PLAN section 2 is complete within its recorded acceptance boundaries. diff --git a/src/game/battle/battle.ts b/src/game/battle/battle.ts index c53b8e2..e052a23 100644 --- a/src/game/battle/battle.ts +++ b/src/game/battle/battle.ts @@ -296,7 +296,7 @@ export function applyTypeMatchups( * native translation. Unsupported AI, switching, contest and item paths are refused. */ /** PokeBallEffect's wFinalCatchRate for Poké, Great and Ultra Balls, in * the original 8- and 16-bit registers. Returns undefined when the - * formula would divide by zero (a maximum HP of 342). */ + * formula would divide by zero (e.g. maximum HP of 342 or 683). */ export function catchRate(input: { rate: number; ball: number; @@ -1310,33 +1310,7 @@ export class Battle { this.wildMon = 0; yield* this.say(BATTLE.ItemUsedText); - let caught = tutorial || item === C.MASTER_BALL; - if (!caught) { - const held = this.data.heldEffect(this.player.mon?.item ?? 0); - const chance = catchRate({ - rate: this.enemyBase.catchRate, - ball: item, - hp: this.enemy.mon.hp, - maxHp: this.enemy.mon.maxHp, - status: this.enemy.mon.status, - held, - }); - if (chance === undefined) throw new UnsupportedBattle("The catch rate divides by zero."); - this.finalCatchRate = chance; - caught = this.random() <= chance; - } - - // BattleAnim_ThrowPokeBall's .Shake loop runs GetPokeBallWobble until - // the ball clicks or the Pokémon breaks free. - let wobbles = 0; - for (;;) { - wobbles++; - if (wobbles === 4) break; - if (caught) continue; - let entry = BATTLE.WobbleProbabilities; - while (this.reader.byte(entry) < this.finalCatchRate) entry += 2; - if (this.random() >= this.reader.byte(entry + 1)) break; - } + const { caught, wobbles } = this.rollCapture(item, tutorial); if (!caught) { yield* this.say( @@ -1410,6 +1384,42 @@ export class Battle { this.tossItem("balls", item, slot); return true; } + /** PokeBallEffect's decision, before animation and caught-mon loading. */ + private rollCapture(item: number, tutorial: boolean) { + let caught = tutorial || item === C.MASTER_BALL; + if (!caught) { + const held = this.data.heldEffect(this.player.mon?.item ?? 0); + const chance = catchRate({ + rate: this.enemyBase.catchRate, + ball: item, + hp: this.enemy.mon.hp, + maxHp: this.enemy.mon.maxHp, + status: this.enemy.mon.status, + held, + }); + if (chance === undefined) throw new UnsupportedBattle("The catch rate divides by zero."); + this.finalCatchRate = chance; + caught = this.random() <= chance; + } + + return { caught, wobbles: this.ballWobbles(caught) }; + } + + private ballWobbles(caught: boolean): number { + // BattleAnim_ThrowPokeBall's .Shake loop runs GetPokeBallWobble until + // the ball clicks or the Pokémon breaks free. + let wobbles = 0; + for (;;) { + wobbles++; + if (wobbles === 4) break; + if (caught) continue; + let entry = BATTLE.WobbleProbabilities; + while (this.reader.byte(entry) < this.finalCatchRate) entry += 2; + if (this.random() >= this.reader.byte(entry + 1)) break; + } + return wobbles; + } + /** AskGiveNicknameText, then NamingScreen (NAME_MON) if accepted: the * chosen name, or the species name for a declined or blank one. */ private *askNickname(name: string): Gen { diff --git a/tests/capture.test.ts b/tests/capture.test.ts index ed115a9..76f777e 100644 --- a/tests/capture.test.ts +++ b/tests/capture.test.ts @@ -40,8 +40,8 @@ describe("catch rate formula", () => { expect(rate({ held: { effect: C.HELD_CATCH_CHANCE, parameter: 5 } })).toBe(90); // Max HP 400: the low bytes wrap, so a full-HP Pokémon gets 67, not 85. expect(rate({ hp: 400, maxHp: 400 })).toBe(67); - // Max HP 342 divides by zero in the original. - expect(rate({ hp: 342, maxHp: 342 })).toBeUndefined(); + // These maximum HP values divide by zero in the original. + for (const maxHp of [342, 683]) expect(rate({ hp: maxHp, maxHp })).toBeUndefined(); }); }); diff --git a/tests/reference-capture.spec.ts b/tests/reference-capture.spec.ts new file mode 100644 index 0000000..d7d8e9d --- /dev/null +++ b/tests/reference-capture.spec.ts @@ -0,0 +1,312 @@ +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 = { + rate: number; + ball: number; + hp: number; + maxHp: number; + status: number; + heldItem: number; + tutorial: boolean; + initialRate: number; + caught: boolean; + rolls: number[]; +}; +const make = (overrides: Partial = {}): Input => ({ + rate: 45, + ball: C.POKE_BALL, + hp: 20, + maxHp: 20, + status: 0, + heldItem: 0, + tutorial: false, + initialRate: 173, + caught: false, + rolls: [255, 0, 128, 255], + ...overrides, +}); + +/** Capture calls hardware Random rather than BattleRandom. Replace only + * Random with a byte-stream reader in the in-page reference ROM. Enter + * PokeBallEffect after its UI setup and return just after wWildMon is set; + * GetPokeBallWobble itself is unmodified. No captured-mon loading or + * animation timing is included in this comparison. */ +async function compare(page: Page, mode: "capture" | "wobble", cases: Input[]) { + const { at, routine } = loadSymbols(); + const addresses = { + start: routine("PokeBallEffect.room_in_party"), + multiplier: at("PokeBallEffect.get_multiplier_loop"), + finish: routine("PokeBallEffect.fail_to_catch"), + wobble: routine("GetPokeBallWobble"), + random: at("Random"), + romBank: at("hROMBank"), + rate: at("wEnemyMonCatchRate"), + finalRate: at("wFinalCatchRate"), + wild: at("wWildMon"), + wobbles: at("wThrownBallWobbleCount"), + item: at("wCurItem"), + battleType: at("wBattleType"), + hp: at("wEnemyMonHP"), + maxHp: at("wEnemyMonMaxHP"), + status: at("wEnemyMonStatus"), + species: at("wEnemyMonSpecies"), + heldItem: at("wBattleMonItem"), + }; + await openReference(page); + const results = await page.evaluate( + async ({ addresses: a, mode, 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.slice()); + const offset = (bank: number, address: number) => + bank * 0x4000 + (address & 0x3fff); + // Locate the first calculation instruction, with both WRAM operands + // checked against symbols rather than assuming a fixed UI byte count. + const pattern = [ + 0xfa, + a.rate & 255, + a.rate >> 8, + 0x47, + 0xfa, + a.battleType & 255, + a.battleType >> 8, + ]; + const starts: number[] = []; + for (let p = a.start.address; p < a.multiplier; p++) { + if ( + pattern.every((byte, i) => rom[offset(a.start.bank, p) + i] === byte) + ) + starts.push(p); + } + if (starts.length !== 1) + throw new Error("Capture calculation entry changed"); + const finish = offset(a.finish.bank, a.finish.address); + if ( + ![0xea, a.wild & 255, a.wild >> 8, 0x0e, 20].every( + (b, i) => rom[finish + i] === b, + ) + ) + throw new Error("Capture calculation exit changed"); + rom[finish + 3] = 0xc9; // RET before DelayFrames and animation setup. + if ( + rom[a.random] !== 0xc5 || + rom[a.random + 1] !== 0xf0 || + rom[a.random + 2] !== 4 + ) + throw new Error("Unexpected hardware Random entry"); + rom.set([0xc3, 0x00, 0xc1], a.random); // JP $c100 (controlled input). + 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); + }; + // Preserve BC/HL, fetch stream[cursor], increment cursor, return in A. + // DE is untouched. The original consumers immediately CP the result. + [ + 0xe5, 0xc5, 0xfa, 0x80, 0xc1, 0x4f, 0x06, 0x00, 0x21, 0x90, 0xc1, 0x09, + 0x46, 0x3c, 0xea, 0x80, 0xc1, 0x78, 0xc1, 0xe1, 0xc9, + ].forEach((byte, i) => write(0xc100 + i, byte)); + const call = (target: { bank: number; address: number }) => { + // DI; SP=$c7fe; select bank; CALL; save C; sentinel; loop. + const program = [ + 0xf3, + 0x31, + 0xfe, + 0xc7, + 0x3e, + target.bank, + 0xe0, + a.romBank & 255, + 0xea, + 0x00, + 0x20, + 0xcd, + target.address & 255, + target.address >> 8, + 0x79, + 0xea, + 0xfe, + 0xc6, + 0x3e, + 0x42, + 0xea, + 0xff, + 0xc6, + 0x18, + 0xfe, + ]; + write(0xc6ff, 0); + program.forEach((byte, i) => write(0xc000 + i, byte)); + reference.setProgramCounter(0xc000); + reference.advanceFrames(1); + if (reference.readMemory(0xc6ff) !== 0x42) + throw new Error("Capture routine did not return"); + return reference.readMemory(0xc6fe); + }; + try { + return cases.map((input, index) => { + let 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 >= input.rolls.length) + throw new Error(`Exhausted capture stream in case ${index}`); + return input.rolls[draws++]; + }, + }); + const internal = battle as unknown as { + finalCatchRate: number; + rollCapture( + item: number, + tutorial: boolean, + ): { caught: boolean; wobbles: number }; + ballWobbles(caught: boolean): number; + }; + battle.enemyBase.catchRate = input.rate; + battle.enemy.mon = { + hp: input.hp, + maxHp: input.maxHp, + status: input.status, + } as never; + battle.player.mon = { item: input.heldItem } as never; + internal.finalCatchRate = input.initialRate; + const native = + mode === "capture" + ? internal.rollCapture(input.ball, input.tutorial) + : { + caught: input.caught, + wobbles: internal.ballWobbles(input.caught), + }; + write(0xff70, 1); + write(a.rate, input.rate); + write(a.finalRate, input.initialRate); + write(a.wild, mode === "wobble" && input.caught ? 19 : 0); + write(a.wobbles, 0); + write(a.item, input.ball); + write(a.battleType, input.tutorial ? 3 : 0); + write(a.species, 19); + write(a.status, input.status); + write(a.heldItem, input.heldItem); + word(a.hp, input.hp); + word(a.maxHp, input.maxHp); + write(0xc180, 0); + // Poison unused bytes so an extra reference draw cannot pass silently. + for (let i = 0; i < 16; i++) + write(0xc190 + i, input.rolls[i] ?? 0xee); + if (mode === "capture") + call({ bank: a.start.bank, address: starts[0] }); + let outcome = 0; + for (let i = 0; i < 4 && outcome === 0; i++) outcome = call(a.wobble); + if (outcome !== 1 && outcome !== 2) + throw new Error("Wobble loop did not finish"); + return { + native: { ...native, rate: internal.finalCatchRate, draws }, + original: { + caught: reference.readMemory(a.wild) !== 0, + wobbles: reference.readMemory(a.wobbles), + rate: reference.readMemory(a.finalRate), + draws: reference.readMemory(0xc180), + }, + outcome, + }; + }); + } finally { + reference.dispose(); + } + }, + { addresses, mode, cases }, + ); + results.forEach((result, i) => { + expect( + result.native, + `${mode} case ${i}: ${JSON.stringify(cases[i])}`, + ).toEqual(result.original); + expect(result.outcome).toBe(result.native.caught ? 1 : 2); + }); +} + +test.beforeEach(() => { + test.skip( + !existsSync(romPath) || !existsSync(symbolsPath), + "Requires the local user ROM and private PRET rebuild.", + ); +}); + +test("capture formula, success and random consumption match the original", async ({ + page, +}) => { + const cases: Input[] = []; + // Every catch-rate byte, all supported multipliers, saturation and odd rounding. + for (const ball of [C.POKE_BALL, C.GREAT_BALL, C.ULTRA_BALL]) + for (let rate = 0; rate < 256; rate++) cases.push(make({ ball, rate })); + // HP scaling boundary, byte wrapping above 341, and the largest legal HP. + // 342 and 683 are omitted: the original divides by zero and hangs. + for (const maxHp of [ + 1, 2, 84, 85, 86, 87, 100, 255, 256, 340, 341, 343, 400, 511, 512, 682, 684, + 703, 713, + ]) + for (const hp of new Set( + [1, 2, Math.floor(maxHp / 2), maxHp - 1, maxHp].filter( + (hp) => hp > 0 && hp <= maxHp, + ), + )) + for (const ball of [C.POKE_BALL, C.GREAT_BALL, C.ULTRA_BALL]) + cases.push(make({ maxHp, hp, ball, rate: 255 })); + // Sleep counts, freeze, and the intentionally ineffective poison/burn/paralysis bits. + for (let status = 0; status < 128; status++) + for (const rate of [1, 45, 255]) + cases.push(make({ status, rate, hp: 1, maxHp: 100 })); + // Exhaust the success draw at several final rates, including equality and 255. + for (const rate of [1, 2, 3, 45, 127, 254, 255]) + for (let roll = 0; roll < 256; roll++) + cases.push(make({ rate, hp: 0, rolls: [roll, 0, 128, 255] })); + for (const heldItem of [0, 0x49, 0x8c, 0xac]) cases.push(make({ heldItem })); + // Neither guaranteed-catch path updates the previous final rate or draws RNG. + for (const initialRate of [0, 1, 173, 255]) { + cases.push(make({ ball: C.MASTER_BALL, initialRate, rolls: [] })); + cases.push(make({ tutorial: true, initialRate, rolls: [] })); + } + await compare(page, "capture", cases); +}); + +test("ball shakes match original table boundaries, outcomes and draw counts", async ({ + page, +}) => { + const cases: Input[] = []; + // Every final rate with early escape, each later escape and all three shakes. + for (let initialRate = 0; initialRate < 256; initialRate++) { + for (const rolls of [[255], [0, 255], [0, 0, 255], [0, 0, 0]]) + cases.push(make({ initialRate, rolls })); + cases.push(make({ initialRate, caught: true, rolls: [] })); + } + // Every random byte at each table entry; neighboring rates are covered by the loop above. + for (const rate of [ + 1, 2, 3, 4, 5, 7, 10, 15, 20, 30, 40, 50, 60, 80, 100, 120, 140, 160, 180, + 200, 220, 240, 254, 255, + ]) + for (let roll = 0; roll < 256; roll++) + cases.push(make({ initialRate: rate, rolls: [roll, 255] })); + await compare(page, "wobble", cases); +}); diff --git a/tests/reference-fixture.ts b/tests/reference-fixture.ts new file mode 100644 index 0000000..dbb3dc8 --- /dev/null +++ b/tests/reference-fixture.ts @@ -0,0 +1,48 @@ +import type { Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +export const romPath = resolve( + process.env.CRYSTAL_ROM_PATH ?? + "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc", +); +export const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym"; + +/** Banked addresses from the private PRET rebuild's symbol file. */ +export function loadSymbols() { + 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, + ] + : []; + }), + ); + return { + at: (name: string) => symbols.get(name)!.address, + routine: (name: string) => symbols.get(name)!, + }; +} + +/** Open the reference page and hand it the ROM through an isolated file + * input, without an HTTP ROM endpoint. */ +export async function openReference(page: Page) { + 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); +} diff --git a/tests/reference-parity.spec.ts b/tests/reference-parity.spec.ts index 9c03ba6..a7837b2 100644 --- a/tests/reference-parity.spec.ts +++ b/tests/reference-parity.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page } from "@playwright/test"; +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"; @@ -14,44 +14,7 @@ import { import { C } from "../src/game/battle/constants"; import { NATIVE_MAPS } from "../src/game/rom/profile"; import { calcStat, maxPP } from "../src/game/party/pokemon"; -import { resolve } from "node:path"; - -const romPath = resolve( - process.env.CRYSTAL_ROM_PATH ?? - "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc", -); -const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym"; - -/** Banked addresses from the private PRET rebuild's symbol file. */ -function loadSymbols() { - 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] - : []; - }), - ); - return { - at: (name: string) => symbols.get(name)!.address, - routine: (name: string) => symbols.get(name)!, - }; -} - -/** Open the reference page and hand it the ROM through an isolated file - * input, without an HTTP ROM endpoint. */ -async function openReference(page: Page) { - 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); -} +import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture"; test("native decompression matches original GB assembly executing in the reference emulator", async ({ page, diff --git a/tests/reference-random.spec.ts b/tests/reference-random.spec.ts new file mode 100644 index 0000000..0c54659 --- /dev/null +++ b/tests/reference-random.spec.ts @@ -0,0 +1,450 @@ +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)); +});