Checks / web (push) Canceled after 0s
The pack's TM/HM pocket lists TMs and HMs with their moves, gives TMs and teaches either through the ABLE/NOT ABLE party menu, the compatibility and "already knows" checks and LearnMove; a TM is used up and makes the Pokémon happier. Eggs show no level or HP in the party menus, get the Egg stats page with its hatching hint, and are skipped by HealParty. Bug Catcher Wade's number registers (VAR_CALLERID and RandomPhoneMon). The playthrough now runs through Falkner's ZEPHYRBADGE, the Togepi Egg, Kyle's Onix, Flash in Dark Cave, Wade's number and Earl's blackboard. With MAKE_TEST_SAVES it writes a Violet City backup for the manual checks. Validated with 610 unit tests, 157 browser tests (including all 256 badge bytes against the original BadgeStatBoosts), native/reference builds and reference/vendor/release checks. Egg hatching (#29) and trainer calls (#30) remain on their cards. Closes #31 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
716 lines
29 KiB
TypeScript
716 lines
29 KiB
TypeScript
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";
|
|
import {
|
|
Battle,
|
|
applyTypeMatchups,
|
|
calculateDamage,
|
|
magikarpLength,
|
|
restoreHealth,
|
|
restoredPP,
|
|
sendOutText,
|
|
withdrawText,
|
|
} from "../src/game/battle/battle";
|
|
import { C } from "../src/game/battle/constants";
|
|
import { NATIVE_MAPS } from "../src/game/rom/profile";
|
|
import { calcStat, maxPP } from "../src/game/party/pokemon";
|
|
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
|
|
|
|
test("native decompression matches original GB assembly executing in the reference emulator", async ({
|
|
page,
|
|
}) => {
|
|
test.skip(!existsSync(romPath), "Requires the local user ROM.");
|
|
await openReference(page);
|
|
const result = await page.evaluate(async () => {
|
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
|
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
|
const { decompress } = await import("/src/game/rom/lz.ts");
|
|
const { SYMBOLS, NATIVE_MAPS } = await import("/src/game/rom/profile.ts");
|
|
const file = (
|
|
document.querySelector("#reference-fixture") as HTMLInputElement
|
|
).files![0];
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
const canvas = document.createElement("canvas");
|
|
const reference = new BinjgbEmulator();
|
|
const assets = new CrystalAssets(bytes);
|
|
await reference.load(bytes, canvas);
|
|
const comparisons: {
|
|
map: string;
|
|
bytes: number;
|
|
equal: boolean;
|
|
returned: boolean;
|
|
}[] = [];
|
|
for (const id of Object.keys(NATIVE_MAPS)) {
|
|
const map = assets.map(id);
|
|
const pointer = assets.reader.far(SYMBOLS.tilesets + map.tileset * 15);
|
|
const bank = Math.floor(pointer / 0x4000),
|
|
address = 0x4000 + (pointer % 0x4000);
|
|
const native = decompress(assets.reader, pointer);
|
|
// Original test trampoline in WRAM: DI; SP=$c7fe; A=bank;
|
|
// HL=compressed ROM address; DE=$d000; CALL FarDecompress ($0b40);
|
|
// write a sentinel and loop. The referenced routine is the user's ROM.
|
|
const program = [
|
|
0xf3,
|
|
0x31,
|
|
0xfe,
|
|
0xc7,
|
|
0x3e,
|
|
bank,
|
|
0x21,
|
|
address & 255,
|
|
address >> 8,
|
|
0x11,
|
|
0,
|
|
0xd0,
|
|
0xcd,
|
|
0x40,
|
|
0x0b,
|
|
0x3e,
|
|
0x42,
|
|
0xea,
|
|
0xff,
|
|
0xc6,
|
|
0x18,
|
|
0xfe,
|
|
];
|
|
reference.writeMemory(0xff70, 6);
|
|
reference.writeMemory(0xff9d, 1);
|
|
reference.writeMemory(0xc6ff, 0);
|
|
program.forEach((value, index) =>
|
|
reference.writeMemory(0xc000 + index, value),
|
|
);
|
|
reference.setProgramCounter(0xc000);
|
|
reference.advanceFrames(30);
|
|
comparisons.push({
|
|
map: id,
|
|
bytes: native.length,
|
|
returned: reference.readMemory(0xc6ff) === 0x42,
|
|
equal: native.every(
|
|
(value, index) => reference.readMemory(0xd000 + index) === value,
|
|
),
|
|
});
|
|
}
|
|
reference.dispose();
|
|
return comparisons;
|
|
});
|
|
expect(result.length).toBe(Object.keys(NATIVE_MAPS).length);
|
|
for (const comparison of result) {
|
|
expect(
|
|
comparison.returned,
|
|
`${comparison.map}: original routine returned`,
|
|
).toBe(true);
|
|
expect(
|
|
comparison.equal,
|
|
`${comparison.map}: ${comparison.bytes} decompressed bytes match`,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("native damage and type effectiveness match the original battle routines", async ({
|
|
page,
|
|
}) => {
|
|
test.skip(
|
|
!existsSync(romPath) || !existsSync(symbolsPath),
|
|
"Requires the local user ROM and the private PRET rebuild.",
|
|
);
|
|
const { at, routine } = loadSymbols();
|
|
const bytes = new Uint8Array(readFileSync(romPath));
|
|
const assets = new CrystalAssets(bytes);
|
|
const data = new BattleData(assets.reader);
|
|
const matchups = data.typeMatchups();
|
|
// A deterministic generator keeps failures reproducible.
|
|
let seed = 0x1234;
|
|
const next = (limit: number) => {
|
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
|
return (seed >>> 8) % limit;
|
|
};
|
|
const types = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 24, 25, 26, 27];
|
|
const effects = [0, 0, 0, C.EFFECT_SELFDESTRUCT, C.EFFECT_MULTI_HIT, C.EFFECT_CONVERSION];
|
|
const damageCases = Array.from({ length: 400 }, () => {
|
|
const item = next(4) === 0 ? next(191) : 0;
|
|
return {
|
|
power: next(8) === 0 ? 0 : next(256),
|
|
level: 1 + next(100),
|
|
attack: next(256),
|
|
defense: next(256),
|
|
effect: effects[next(effects.length)],
|
|
critical: next(2),
|
|
current: next(5) === 0 ? next(1200) : 0,
|
|
type: types[next(types.length)],
|
|
item,
|
|
};
|
|
});
|
|
const stabCases = Array.from({ length: 400 }, () => ({
|
|
move: 1 + next(164),
|
|
type: types[next(types.length)],
|
|
user: [types[next(types.length)], types[next(types.length)]],
|
|
target: [types[next(types.length)], types[next(types.length)]],
|
|
identified: next(4) === 0,
|
|
damage: next(1000),
|
|
}));
|
|
await openReference(page);
|
|
const addresses = {
|
|
curDamage: at("wCurDamage"),
|
|
critical: at("wCriticalHit"),
|
|
turn: at("hBattleTurn"),
|
|
playerMove: at("wPlayerMoveStruct"),
|
|
battleItem: at("wBattleMonItem"),
|
|
battleTypes: at("wBattleMonType1"),
|
|
enemyTypes: at("wEnemyMonType1"),
|
|
typeModifier: at("wTypeModifier"),
|
|
attackMissed: at("wAttackMissed"),
|
|
weather: at("wBattleWeather"),
|
|
johto: at("wJohtoBadges"),
|
|
kanto: at("wKantoBadges"),
|
|
link: at("wLinkMode"),
|
|
tower: at("wInBattleTowerBattle"),
|
|
enemySub1: at("wEnemySubStatus1"),
|
|
damageCalc: routine("BattleCommand_DamageCalc"),
|
|
stab: routine("BattleCommand_Stab"),
|
|
};
|
|
const results = await page.evaluate(
|
|
async ({ addresses, damageCases, stabCases }) => {
|
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
|
const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0];
|
|
const reference = new BinjgbEmulator();
|
|
await reference.load(new Uint8Array(await file.arrayBuffer()), document.createElement("canvas"));
|
|
const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff);
|
|
const word = (address: number, value: number) => {
|
|
write(address, value >> 8);
|
|
write(address + 1, value);
|
|
};
|
|
// Trampoline: DI; SP=$c7fe; switch to the routine's bank; set B, C, D, E;
|
|
// CALL the original routine; write a sentinel; loop.
|
|
const call = (target: { bank: number; address: number }, registers: number[]) => {
|
|
const program = [
|
|
0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20,
|
|
0x06, registers[0], 0x0e, registers[1], 0x16, registers[2], 0x1e, registers[3],
|
|
0xcd, target.address & 0xff, target.address >> 8,
|
|
0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe,
|
|
];
|
|
write(0xc6ff, 0);
|
|
program.forEach((value, index) => write(0xc000 + index, value));
|
|
reference.setProgramCounter(0xc000);
|
|
reference.advanceFrames(2);
|
|
return reference.readMemory(0xc6ff) === 0x42;
|
|
};
|
|
write(0xff70, 1); // WRAM bank 1 holds wCurDamage and wEnemyMon
|
|
for (const address of [addresses.weather, addresses.johto, addresses.kanto, addresses.link, addresses.tower])
|
|
write(address, 0);
|
|
write(addresses.turn, 0);
|
|
const damage = damageCases.map((input) => {
|
|
write(addresses.playerMove + 1, input.effect);
|
|
write(addresses.playerMove + 3, input.type);
|
|
write(addresses.battleItem, input.item);
|
|
write(addresses.critical, input.critical);
|
|
word(addresses.curDamage, input.current);
|
|
// B = attack, C = defense, D = power, E = level
|
|
const returned = call(addresses.damageCalc, [input.attack, input.defense, input.power, input.level]);
|
|
return {
|
|
returned,
|
|
damage: (reference.readMemory(addresses.curDamage) << 8) | reference.readMemory(addresses.curDamage + 1),
|
|
};
|
|
});
|
|
const stab = stabCases.map((input) => {
|
|
write(addresses.playerMove, input.move);
|
|
write(addresses.playerMove + 3, input.type);
|
|
write(addresses.battleTypes, input.user[0]);
|
|
write(addresses.battleTypes + 1, input.user[1]);
|
|
write(addresses.enemyTypes, input.target[0]);
|
|
write(addresses.enemyTypes + 1, input.target[1]);
|
|
write(addresses.enemySub1, input.identified ? 1 << 3 : 0);
|
|
write(addresses.typeModifier, 10);
|
|
write(addresses.attackMissed, 0);
|
|
word(addresses.curDamage, input.damage);
|
|
const returned = call(addresses.stab, [0, 0, 0, 0]);
|
|
return {
|
|
returned,
|
|
damage: (reference.readMemory(addresses.curDamage) << 8) | reference.readMemory(addresses.curDamage + 1),
|
|
modifier: reference.readMemory(addresses.typeModifier),
|
|
missed: reference.readMemory(addresses.attackMissed),
|
|
};
|
|
});
|
|
reference.dispose();
|
|
return { damage, stab };
|
|
},
|
|
{ addresses, damageCases, stabCases },
|
|
);
|
|
damageCases.forEach((input, index) => {
|
|
const original = results.damage[index];
|
|
expect(original.returned, `damage case ${index} returned`).toBe(true);
|
|
const held = data.heldEffect(input.item);
|
|
const boost =
|
|
held.effect && data.typeBoost(held.effect) === input.type ? held.parameter : undefined;
|
|
const native =
|
|
calculateDamage({
|
|
power: input.power,
|
|
level: input.level,
|
|
attack: input.attack,
|
|
defense: input.defense,
|
|
effect: input.effect,
|
|
critical: Boolean(input.critical),
|
|
boost,
|
|
current: input.current,
|
|
}) ?? input.current;
|
|
expect(native, `damage case ${index}: ${JSON.stringify(input)}`).toBe(original.damage);
|
|
});
|
|
stabCases.forEach((input, index) => {
|
|
const original = results.stab[index];
|
|
expect(original.returned, `type case ${index} returned`).toBe(true);
|
|
const native = applyTypeMatchups(matchups, {
|
|
damage: input.damage,
|
|
modifier: 10,
|
|
moveType: input.type,
|
|
userTypes: input.user,
|
|
targetTypes: input.target,
|
|
identified: input.identified,
|
|
});
|
|
expect(
|
|
[native.damage, native.modifier, Number(native.missed)],
|
|
`type case ${index}: ${JSON.stringify(input)}`,
|
|
).toEqual([original.damage, original.modifier, original.missed]);
|
|
});
|
|
});
|
|
|
|
test("native stats and experience curves match the original routines", async ({ page }) => {
|
|
test.skip(
|
|
!existsSync(romPath) || !existsSync(symbolsPath),
|
|
"Requires the local user ROM and the private PRET rebuild.",
|
|
);
|
|
const { at, routine } = loadSymbols();
|
|
const pokemon = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon;
|
|
let seed = 0x5eed;
|
|
const next = (limit: number) => {
|
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
|
return (seed >>> 8) % limit;
|
|
};
|
|
// Legal stats stay below 999; levels above 100 reach the MAX_STAT_VALUE cap.
|
|
const statCases = Array.from({ length: 300 }, () => ({
|
|
base: Array.from({ length: 6 }, () => (next(4) === 0 ? 200 + next(56) : 1 + next(255))),
|
|
level: next(6) === 0 ? 101 + next(155) : next(5) === 0 ? 100 : 1 + next(100),
|
|
dvs: next(0x10000),
|
|
statExp: Array.from({ length: 5 }, () => (next(3) === 0 ? 0xffff - next(256) : next(0x10000))),
|
|
useStatExp: next(4) !== 0,
|
|
}));
|
|
await openReference(page);
|
|
const addresses = {
|
|
baseStats: at("wBaseStats"),
|
|
level: at("wCurPartyLevel"),
|
|
growthRate: at("wBaseGrowthRate"),
|
|
product: at("hProduct"),
|
|
calcMonStats: routine("CalcMonStats"),
|
|
calcExpAtLevel: routine("CalcExpAtLevel"),
|
|
};
|
|
const results = await page.evaluate(
|
|
async ({ addresses, statCases }) => {
|
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
|
const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0];
|
|
const reference = new BinjgbEmulator();
|
|
await reference.load(new Uint8Array(await file.arrayBuffer()), document.createElement("canvas"));
|
|
const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff);
|
|
const read = (address: number, length: number) =>
|
|
Array.from({ length }, (_, i) => reference.readMemory(address + i));
|
|
// Trampoline: DI; SP=$c7fe; switch to the routine's bank; set BC, DE
|
|
// and HL; CALL the original routine; write a sentinel; loop.
|
|
const call = (
|
|
target: { bank: number; address: number },
|
|
bc: number,
|
|
de: number,
|
|
hl: number,
|
|
frames = 2,
|
|
) => {
|
|
const program = [
|
|
0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20,
|
|
0x01, bc & 0xff, bc >> 8, 0x11, de & 0xff, de >> 8, 0x21, hl & 0xff, hl >> 8,
|
|
0xcd, target.address & 0xff, target.address >> 8,
|
|
0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe,
|
|
];
|
|
write(0xc6ff, 0);
|
|
program.forEach((value, index) => write(0xc000 + index, value));
|
|
reference.setProgramCounter(0xc000);
|
|
reference.advanceFrames(frames);
|
|
return reference.readMemory(0xc6ff) === 0x42;
|
|
};
|
|
write(0xff70, 1); // WRAM bank 1 holds the base data and party level
|
|
// A party struct at $c100 (stat experience at +11, DVs at +21) and
|
|
// the six computed stats at $c140.
|
|
const mon = 0xc100,
|
|
out = 0xc140;
|
|
const stats = statCases.map((input) => {
|
|
input.base.forEach((value, i) => write(addresses.baseStats + i, value));
|
|
write(addresses.level, input.level);
|
|
input.statExp.forEach((value, i) => {
|
|
write(mon + 11 + i * 2, value >> 8);
|
|
write(mon + 12 + i * 2, value);
|
|
});
|
|
write(mon + 21, input.dvs >> 8);
|
|
write(mon + 22, input.dvs);
|
|
// B = stat experience flag; HL = MON_STAT_EXP - 1; DE = output.
|
|
// GetSquareRoot counts up to 255 per stat, so allow more frames.
|
|
const returned = call(addresses.calcMonStats, Number(input.useStatExp) << 8, out, mon + 10, 8);
|
|
const bytes = read(out, 12);
|
|
return { returned, stats: Array.from({ length: 6 }, (_, i) => (bytes[i * 2] << 8) | bytes[i * 2 + 1]) };
|
|
});
|
|
const experience: { returned: boolean; exp: number }[] = [];
|
|
for (let growth = 0; growth < 6; growth++)
|
|
for (let level = 1; level <= 100; level++) {
|
|
write(addresses.growthRate, growth);
|
|
// D = level
|
|
const returned = call(addresses.calcExpAtLevel, 0, level << 8, 0);
|
|
const [high, middle, low] = read(addresses.product + 1, 3);
|
|
experience.push({ returned, exp: (high << 16) | (middle << 8) | low });
|
|
}
|
|
reference.dispose();
|
|
return { stats, experience };
|
|
},
|
|
{ addresses, statCases },
|
|
);
|
|
statCases.forEach((input, index) => {
|
|
const original = results.stats[index];
|
|
expect(original.returned, `stat case ${index} returned`).toBe(true);
|
|
const native = input.base.map((base, stat) =>
|
|
calcStat(base, input.dvs, stat, input.level, input.useStatExp ? input.statExp : undefined),
|
|
);
|
|
expect(native, `stat case ${index}: ${JSON.stringify(input)}`).toEqual(original.stats);
|
|
});
|
|
expect(results.stats.some(({ stats }) => stats.includes(999))).toBe(true);
|
|
results.experience.forEach((original, index) => {
|
|
const growth = Math.floor(index / 100),
|
|
level = (index % 100) + 1;
|
|
expect(original.returned, `experience ${growth}/${level} returned`).toBe(true);
|
|
expect(pokemon.expAtLevel(growth, level), `experience ${growth}/${level}`).toBe(original.exp);
|
|
});
|
|
});
|
|
|
|
test("native switch texts, healing, PP, happiness and CalcPlayerStats match the original routines", async ({
|
|
page,
|
|
}) => {
|
|
test.skip(
|
|
!existsSync(romPath) || !existsSync(symbolsPath),
|
|
"Requires the local user ROM and the private PRET rebuild.",
|
|
);
|
|
const { at, routine } = loadSymbols();
|
|
const bytes = new Uint8Array(readFileSync(romPath));
|
|
const assets = new CrystalAssets(bytes);
|
|
const pokemon = assets.pokemon;
|
|
let seed = 0xbeef;
|
|
const next = (limit: number) => {
|
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
|
return (seed >>> 8) % limit;
|
|
};
|
|
const maxHp = () => (next(4) === 0 ? 4 + next(60) : 4 + next(996));
|
|
const cheerCases = Array.from({ length: 300 }, () => {
|
|
const max = maxHp();
|
|
return { max, hp: next(max + 1), atSwitch: next(max + 1) };
|
|
});
|
|
const healthCases = Array.from({ length: 300 }, () => {
|
|
const max = 1 + next(999);
|
|
const amounts = [10, 20, 50, 60, 80, 100, 200, 999, next(0x10000)];
|
|
return { max, hp: next(max + 1), amount: amounts[next(amounts.length)] };
|
|
});
|
|
const ppItems = [C.MAX_ELIXER, 0x3f, C.MAX_ETHER, C.ELIXER, C.MYSTERYBERRY];
|
|
const ppCases = Array.from({ length: 200 }, () => ({
|
|
move: 1 + next(251),
|
|
pp: (next(4) << 6) | next(64),
|
|
item: ppItems[next(ppItems.length)],
|
|
}));
|
|
const happinessCases = Array.from({ length: 300 }, () => ({
|
|
happiness: next(256),
|
|
action: 1 + next(19),
|
|
cursor: next(7),
|
|
mon: next(6),
|
|
egg: next(8) === 0,
|
|
}));
|
|
const statCases = Array.from({ length: 200 }, () => ({
|
|
levels: Array.from({ length: 5 }, () => 1 + next(13)),
|
|
stats: Array.from({ length: 5 }, () => 1 + next(999)),
|
|
status: [0, 1 << 6, 1 << 4, 1 << 3][next(4)],
|
|
enemyStatus: [0, 1 << 6, 1 << 4][next(3)],
|
|
enemyStats: Array.from({ length: 5 }, () => 1 + next(999)),
|
|
turn: next(2),
|
|
}));
|
|
await openReference(page);
|
|
const skipToTextbox = routine("SendOutMonText.skip_to_textbox");
|
|
const addresses = {
|
|
// The ROM copy loaded in the page gets `ret` instead of SendOutMonText's
|
|
// final `jp BattleTextbox`, so the routine returns its text in HL.
|
|
patch: skipToTextbox.bank * 0x4000 + skipToTextbox.address - 0x4000,
|
|
sendOut: routine("SendOutMonText"),
|
|
withdraw: { ...routine("WithdrawMonText.WithdrawMonText"), address: at("WithdrawMonText.WithdrawMonText") + 5 },
|
|
restoreHealth: routine("RestoreHealth"),
|
|
restorePP: routine("RestorePP"),
|
|
changeHappiness: routine("ChangeHappiness"),
|
|
calcPlayerStats: routine("CalcPlayerStats"),
|
|
multiplicand: at("hMultiplicand"),
|
|
enemyHP: at("wEnemyMonHP"),
|
|
enemyMaxHP: at("wEnemyMonMaxHP"),
|
|
hpAtSwitch: at("wEnemyHPAtTimeOfPlayerSwitch"),
|
|
link: at("wLinkMode"),
|
|
curPartyMon: at("wCurPartyMon"),
|
|
partyMon1: at("wPartyMon1"),
|
|
partySpecies: at("wPartySpecies"),
|
|
partyCount: at("wPartyCount"),
|
|
menuCursorY: at("wMenuCursorY"),
|
|
hpBuffer1: at("wHPBuffer1"),
|
|
ppItem: at("wTempRestorePPItem"),
|
|
monType: at("wMonType"),
|
|
battleMode: at("wBattleMode"),
|
|
partyMenuCursor: at("wPartyMenuCursor"),
|
|
battleMonHappiness: at("wBattleMonHappiness"),
|
|
playerLevels: at("wPlayerAtkLevel"),
|
|
playerStats: at("wPlayerStats"),
|
|
battleMonStats: at("wBattleMonAttack"),
|
|
battleMonStatus: at("wBattleMonStatus"),
|
|
enemyStatus: at("wEnemyMonStatus"),
|
|
enemyStats: at("wEnemyMonAttack"),
|
|
johto: at("wJohtoBadges"),
|
|
turn: at("hBattleTurn"),
|
|
text: {
|
|
GoMonText: at("GoMonText"),
|
|
DoItMonText: at("DoItMonText"),
|
|
GoForItMonText: at("GoForItMonText"),
|
|
YourFoesWeakGetmMonText: at("YourFoesWeakGetmMonText"),
|
|
ThatsEnoughComeBackText: at("ThatsEnoughComeBackText"),
|
|
ComeBackText: at("ComeBackText"),
|
|
OKComeBackText: at("OKComeBackText"),
|
|
GoodComeBackText: at("GoodComeBackText"),
|
|
},
|
|
};
|
|
const results = await page.evaluate(
|
|
async ({ addresses, cheerCases, healthCases, ppCases, happinessCases, statCases }) => {
|
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
|
const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0];
|
|
const rom = new Uint8Array(await file.arrayBuffer());
|
|
if (rom[addresses.patch] !== 0xc3) throw new Error("Unexpected SendOutMonText ending.");
|
|
rom[addresses.patch] = 0xc9;
|
|
const reference = new BinjgbEmulator();
|
|
await reference.load(rom, document.createElement("canvas"));
|
|
const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff);
|
|
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);
|
|
// Trampoline: DI; SP=$c7fe; switch banks; set BC and DE; CALL; store
|
|
// HL at $c6fd; write a sentinel; loop.
|
|
const call = (target: { bank: number; address: number }, bc = 0, de = 0) => {
|
|
const program = [
|
|
0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20,
|
|
0x01, bc & 0xff, bc >> 8, 0x11, de & 0xff, de >> 8,
|
|
0xcd, target.address & 0xff, target.address >> 8,
|
|
0x7d, 0xea, 0xfd, 0xc6, 0x7c, 0xea, 0xfe, 0xc6,
|
|
0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe,
|
|
];
|
|
write(0xc6ff, 0);
|
|
program.forEach((value, index) => write(0xc000 + index, value));
|
|
reference.setProgramCounter(0xc000);
|
|
reference.advanceFrames(2);
|
|
if (reference.readMemory(0xc6ff) !== 0x42) throw new Error(`Routine at ${target.address.toString(16)} did not return.`);
|
|
return reference.readMemory(0xc6fd) | (reference.readMemory(0xc6fe) << 8);
|
|
};
|
|
write(0xff70, 1);
|
|
write(addresses.link, 0);
|
|
write(addresses.johto, 0);
|
|
const textName = (hl: number) =>
|
|
Object.entries(addresses.text).find(([, address]) => address === hl)?.[0] ?? `0x${hl.toString(16)}`;
|
|
const cheers = cheerCases.map((input) => {
|
|
word(addresses.enemyHP, input.hp);
|
|
word(addresses.enemyMaxHP, input.max);
|
|
const sendOut = textName(call(addresses.sendOut));
|
|
word(addresses.hpAtSwitch, input.atSwitch);
|
|
write(addresses.multiplicand, 0);
|
|
const withdraw = textName(call(addresses.withdraw));
|
|
return { sendOut, withdraw };
|
|
});
|
|
const mon = addresses.partyMon1;
|
|
write(addresses.curPartyMon, 0);
|
|
const health = healthCases.map((input) => {
|
|
word(mon + 34, input.hp);
|
|
word(mon + 36, input.max);
|
|
// ItemRestoreHP's IsMonFainted loads the maximum into wHPBuffer1
|
|
// (low byte first), which a full restore copies.
|
|
write(addresses.hpBuffer1, input.max);
|
|
write(addresses.hpBuffer1 + 1, input.max >> 8);
|
|
call(addresses.restoreHealth, 0, input.amount);
|
|
return readWord(mon + 34);
|
|
});
|
|
const pp = ppCases.map((input) => {
|
|
write(mon + 2, input.move);
|
|
write(mon + 23, input.pp);
|
|
write(addresses.menuCursorY, 0);
|
|
write(addresses.ppItem, input.item);
|
|
call(addresses.restorePP);
|
|
return reference.readMemory(mon + 23);
|
|
});
|
|
write(addresses.partyCount, 6);
|
|
write(addresses.battleMode, 1);
|
|
const happiness = happinessCases.map((input) => {
|
|
for (let slot = 0; slot < 6; slot++) {
|
|
write(addresses.partySpecies + slot, input.egg && slot === input.mon ? 0xfd : 155);
|
|
write(mon + slot * 48 + 27, input.happiness);
|
|
}
|
|
write(addresses.curPartyMon, input.mon);
|
|
write(addresses.partyMenuCursor, input.cursor);
|
|
write(addresses.battleMonHappiness, 0x55);
|
|
call(addresses.changeHappiness, input.action);
|
|
return {
|
|
party: reference.readMemory(mon + input.mon * 48 + 27),
|
|
battle: reference.readMemory(addresses.battleMonHappiness),
|
|
};
|
|
});
|
|
const stats = statCases.map((input) => {
|
|
input.levels.forEach((level, i) => write(addresses.playerLevels + i, level));
|
|
input.stats.forEach((value, i) => word(addresses.playerStats + i * 2, value));
|
|
input.enemyStats.forEach((value, i) => word(addresses.enemyStats + i * 2, value));
|
|
write(addresses.battleMonStatus, input.status);
|
|
write(addresses.enemyStatus, input.enemyStatus);
|
|
write(addresses.turn, input.turn);
|
|
call(addresses.calcPlayerStats);
|
|
return {
|
|
player: Array.from({ length: 5 }, (_, i) => readWord(addresses.battleMonStats + i * 2)),
|
|
enemy: Array.from({ length: 5 }, (_, i) => readWord(addresses.enemyStats + i * 2)),
|
|
turn: reference.readMemory(addresses.turn),
|
|
};
|
|
});
|
|
reference.dispose();
|
|
return { cheers, health, pp, happiness, stats };
|
|
},
|
|
{ addresses, cheerCases, healthCases, ppCases, happinessCases, statCases },
|
|
);
|
|
cheerCases.forEach((input, index) => {
|
|
expect(
|
|
{ sendOut: sendOutText(input.hp, input.max), withdraw: withdrawText(input.atSwitch, input.hp, input.max) },
|
|
`cheer case ${index}: ${JSON.stringify(input)}`,
|
|
).toEqual(results.cheers[index]);
|
|
});
|
|
healthCases.forEach((input, index) =>
|
|
expect(restoreHealth(input.hp, input.max, input.amount), `health case ${index}: ${JSON.stringify(input)}`).toBe(
|
|
results.health[index],
|
|
),
|
|
);
|
|
ppCases.forEach((input, index) => {
|
|
const max = maxPP(pokemon, input.move, input.pp >> 6) & 63;
|
|
expect(restoredPP(input.pp, max, input.item) ?? input.pp, `PP case ${index}: ${JSON.stringify(input)}`).toBe(
|
|
results.pp[index],
|
|
);
|
|
});
|
|
// ChangeHappiness and CalcPlayerStats run on a battle's own state.
|
|
const battle = new Battle({
|
|
data: pokemon,
|
|
party: [],
|
|
pokedex: { seen: new Set(), caught: new Set() },
|
|
playerId: 1,
|
|
playerName: "CHRIS",
|
|
timeOfDay: 1,
|
|
random: () => 0,
|
|
text: () => ({}) as never,
|
|
});
|
|
const internals = battle as unknown as {
|
|
options: { party: { species: number; happiness: number; egg?: true }[] };
|
|
partyMenuCursor: number;
|
|
turn: number;
|
|
changeHappiness(index: number, action: number): void;
|
|
calcPlayerStats(): void;
|
|
};
|
|
happinessCases.forEach((input, index) => {
|
|
// An Egg is EGG in wPartySpecies and an egg flag here.
|
|
internals.options.party = Array.from({ length: 6 }, (_, slot) => ({
|
|
species: 155,
|
|
...(input.egg && slot === input.mon && { egg: true }),
|
|
happiness: input.happiness,
|
|
}));
|
|
internals.partyMenuCursor = input.cursor;
|
|
battle.player.mon = { happiness: 0x55 } as never;
|
|
internals.changeHappiness(input.mon, input.action);
|
|
expect(
|
|
{ party: internals.options.party[input.mon].happiness, battle: battle.player.mon.happiness },
|
|
`happiness case ${index}: ${JSON.stringify(input)}`,
|
|
).toEqual(results.happiness[index]);
|
|
});
|
|
statCases.forEach((input, index) => {
|
|
battle.player.levels = [...input.levels, 7, 7];
|
|
battle.player.stats = [...input.stats];
|
|
battle.player.mon = { stats: [0, 0, 0, 0, 0], status: input.status } as never;
|
|
battle.enemy.mon = { stats: [...input.enemyStats], status: input.enemyStatus } as never;
|
|
internals.turn = input.turn;
|
|
internals.calcPlayerStats();
|
|
expect(
|
|
{ player: battle.player.mon.stats, enemy: battle.enemy.mon.stats, turn: internals.turn },
|
|
`stat case ${index}: ${JSON.stringify(input)}`,
|
|
).toEqual(results.stats[index]);
|
|
});
|
|
});
|
|
|
|
test("native Magikarp lengths match CalcMagikarpLength", async ({ page }) => {
|
|
test.skip(
|
|
!existsSync(romPath) || !existsSync(symbolsPath),
|
|
"Requires the local user ROM and the private PRET rebuild.",
|
|
);
|
|
const { at, routine } = loadSymbols();
|
|
const reader = new CrystalAssets(new Uint8Array(readFileSync(romPath))).reader;
|
|
let seed = 0x4a39;
|
|
const next = (limit: number) => {
|
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
|
return (seed >>> 8) % limit;
|
|
};
|
|
// Random DVs and IDs, plus indexes below 10 and at the table's edges.
|
|
const cases = [
|
|
...Array.from({ length: 1500 }, () => ({ dvs: next(0x10000), id: next(0x10000) })),
|
|
...[0x0000, 0x0009, 0x000a, 0x00ff, 0x0100, 0xfdff, 0xfe00, 0xff00, 0xffff].map((dvs) => ({
|
|
dvs,
|
|
id: 0,
|
|
})),
|
|
];
|
|
await openReference(page);
|
|
const addresses = {
|
|
calc: routine("CalcMagikarpLength"),
|
|
length: at("wMagikarpLength"),
|
|
};
|
|
const results = await page.evaluate(
|
|
async ({ addresses, cases }) => {
|
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
|
const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0];
|
|
const reference = new BinjgbEmulator();
|
|
await reference.load(new Uint8Array(await file.arrayBuffer()), document.createElement("canvas"));
|
|
const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff);
|
|
// Trampoline: DI; SP=$c7fe; switch to the routine's bank; set BC (the
|
|
// ID) and DE (the DVs); CALL CalcMagikarpLength; write a sentinel; loop.
|
|
const dvs = 0xc100,
|
|
id = 0xc102;
|
|
const program = [
|
|
0xf3, 0x31, 0xfe, 0xc7, 0x3e, addresses.calc.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20,
|
|
0x01, id & 0xff, id >> 8, 0x11, dvs & 0xff, dvs >> 8,
|
|
0xcd, addresses.calc.address & 0xff, addresses.calc.address >> 8,
|
|
0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe,
|
|
];
|
|
write(0xff70, 1);
|
|
const lengths = cases.map((input) => {
|
|
write(dvs, input.dvs >> 8);
|
|
write(dvs + 1, input.dvs);
|
|
write(id, input.id >> 8);
|
|
write(id + 1, input.id);
|
|
write(0xc6ff, 0);
|
|
program.forEach((value, index) => write(0xc000 + index, value));
|
|
reference.setProgramCounter(0xc000);
|
|
reference.advanceFrames(2);
|
|
return {
|
|
returned: reference.readMemory(0xc6ff) === 0x42,
|
|
feet: reference.readMemory(addresses.length),
|
|
inches: reference.readMemory(addresses.length + 1),
|
|
};
|
|
});
|
|
reference.dispose();
|
|
return lengths;
|
|
},
|
|
{ addresses, cases },
|
|
);
|
|
cases.forEach((input, index) => {
|
|
const { returned, ...original } = results[index];
|
|
expect(returned, `case ${index} returned`).toBe(true);
|
|
expect(magikarpLength(reader, input.dvs, input.id), JSON.stringify(input)).toEqual(original);
|
|
});
|
|
const lengths = new Set(results.map(({ feet, inches }) => feet * 12 + inches));
|
|
expect(lengths.size).toBeGreaterThan(30);
|
|
});
|