Verify random battle and capture routines against the original engine
Checks / web (push) Canceled after 0s
Checks / web (push) Canceled after 0s
This commit is contained in:
1 parent
be7575a4d0
commit
00972bad10
9 files changed
+978
-74
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
Reference in new issue
Block a user