Verify random battle and capture routines against the original engine
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-30 15:48:00 +02:00
1 parent be7575a4d0
commit 00972bad10
9 files changed
+978 -74

No files matched your search

+450
View File
@@ -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> = {}): 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));
});