313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
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);
|
|
});
|