Files
Crystal/tests/reference-parity.spec.ts
kawaandClaude Opus 5.5 d3e3faff72
Checks / web (push) Failing after 19s
Private ROM checks / private-rom (push) Failing after 27m7s
Measure, weigh and name the European releases' own way (#84)
The French, German, Italian and Spanish releases store Magikarp's
length in millimetres (and print a tenth of them), start the Magikarp
house's record at their own length and holder, keep Pokédex heights in
decimetres and weights in hectograms (printed in metres and kilograms),
read the Heavy Ball's weight byte without converting it, and place the
enemy's nickname before EnemyText (French, Italian) or the trainer's
name before the class (French, Italian, Spanish).

map-revision --symbols writes each release's symbol file, so the
reference specs run against a release; specs that pin English data skip
there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:04:51 +02:00

719 lines
30 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,
magikarpLengthBytes,
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);
// wMagikarpLength's bytes: feet and inches, or a European release's
// millimetres.
const [feet, inches] = magikarpLengthBytes(reader, input.dvs, input.id);
expect({ feet, inches }, JSON.stringify(input)).toEqual(original);
});
const lengths = new Set(results.map(({ feet, inches }) => feet * 256 + inches));
expect(lengths.size).toBeGreaterThan(30);
});