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>
95 lines
5.0 KiB
TypeScript
95 lines
5.0 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
|
import { existsSync } from "node:fs";
|
|
import { romPath, symbolsPath, loadSymbols, openReference, revisionRoms } from "./reference-fixture";
|
|
|
|
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM/reference.");
|
|
// The English cartridges share 1.1's symbols; not run with another's.
|
|
test.skip(Boolean(process.env.CRYSTAL_SYMBOLS_PATH), "Compares the English cartridges.");
|
|
|
|
/** BattleTowerText on each English cartridge, with PrintTextboxTextAt
|
|
* patched to keep the text pointer: for every BattleTowerTrainers entry,
|
|
* several hRandomAdd values and each of the three texts, the pointer matches
|
|
* battleTowerText's. Revision 1.0 indexes BTTrainerClassGenders with the
|
|
* name's sixth character instead of the class. */
|
|
for (const [revision, rom] of [["1.1", romPath], ["1.0", revisionRoms["1.0"]], ["au", revisionRoms.au]] as const)
|
|
test(`BattleTowerText matches the ${revision} cartridge`, async ({ page }) => {
|
|
test.skip(!existsSync(rom), `Needs PRET's ${revision} rebuild.`);
|
|
test.setTimeout(120_000);
|
|
const { at, routine } = loadSymbols();
|
|
const a = {
|
|
romBank: at("hROMBank"),
|
|
program: at("wOverworldMapBlocks"),
|
|
stackTop: at("wStackTop"),
|
|
randomAdd: at("hRandomAdd"),
|
|
name: routine("wBT_OTName"),
|
|
textIndex: routine("wBT_TrainerTextIndex"),
|
|
};
|
|
const routines = { text: routine("BattleTowerText"), print: routine("PrintTextboxTextAt") };
|
|
await openReference(page, rom);
|
|
const result = await page.evaluate(
|
|
async ({ a, routines, revision }) => {
|
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
|
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
|
const { romRevision } = await import("/src/game/rom/revision.ts");
|
|
const { JOHTO } = await import("/src/game/rom/profile.ts");
|
|
const { battleTowerText, emptyBattleTowerState } = await import("/src/game/scripts/battle-tower.ts");
|
|
const rom = new Uint8Array(
|
|
await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer(),
|
|
);
|
|
const assets = new CrystalAssets(rom);
|
|
const r = assets.reader;
|
|
// PrintTextboxTextAt: keep HL at program + $f0 and return.
|
|
const kept = a.program + 0xf0;
|
|
[0x7d, 0xea, kept & 255, kept >> 8, 0x7c, 0xea, (kept + 1) & 255, (kept + 1) >> 8, 0xc9].forEach(
|
|
(value, i) => (rom[routines.print.address + i] = value),
|
|
);
|
|
const reference = new BinjgbEmulator();
|
|
await reference.load(rom, document.createElement("canvas"));
|
|
reference.setMuted(true);
|
|
const read = (p: number) => reference.readMemory(p);
|
|
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
|
reference.advanceFrames(300);
|
|
const sentinel = a.program + 0xf8;
|
|
const failures: unknown[] = [];
|
|
let compared = 0;
|
|
for (let trainer = 0; trainer < 70; trainer++)
|
|
for (const roll of [0x00, 0x0e, 0x13, 0x1f, 0x9b, 0xff]) {
|
|
const bt = emptyBattleTowerState();
|
|
const entry = JOHTO.BattleTowerTrainers + trainer * 11;
|
|
bt.opponent = { trainer, name: "", trainerClass: r.byte(entry + 10), party: [] };
|
|
for (const which of [1, 2, 3] as const) {
|
|
write(0xff70, a.name.bank);
|
|
for (let i = 0; i < 11; i++) write(a.name.address + i, r.byte(entry + i));
|
|
write(0xff70, 1);
|
|
write(a.randomAdd, roll);
|
|
write(sentinel, 0);
|
|
const program = [
|
|
0xf3, 0x31, a.stackTop & 255, a.stackTop >> 8,
|
|
0x3e, routines.text.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
|
|
0x0e, which,
|
|
0xcd, routines.text.address & 255, routines.text.address >> 8,
|
|
0x3e, 0x42, 0xea, sentinel & 255, sentinel >> 8,
|
|
0x18, 0xfe,
|
|
];
|
|
program.forEach((v, i) => write(a.program + i, v));
|
|
reference.setProgramCounter(a.program);
|
|
for (let frame = 0; frame < 10 && read(sentinel) !== 0x42; frame++) reference.advanceFrames(1);
|
|
const original = read(kept) | (read(kept + 1) << 8);
|
|
// A flat offset in BattleTowerText's bank, as the CPU sees it.
|
|
const flat = battleTowerText({ reader: r, random: () => roll } as never, bt, which);
|
|
const native = r.bank(flat) === routines.text.bank ? (flat & 0x3fff) | 0x4000 : -flat;
|
|
compared++;
|
|
if (read(sentinel) !== 0x42 || original !== native)
|
|
failures.push({ trainer, roll, which, original: original.toString(16), native: native.toString(16) });
|
|
}
|
|
}
|
|
reference.dispose();
|
|
return { revision: romRevision(r), compared, failures: failures.slice(0, 8), failed: failures.length };
|
|
},
|
|
{ a, routines, revision },
|
|
);
|
|
expect(result.revision).toBe(revision);
|
|
expect(result.failures, JSON.stringify(result)).toEqual([]);
|
|
expect(result.compared).toBe(70 * 6 * 3);
|
|
});
|