Read the English 1.0 and Australian cartridges besides 1.1 (#84)
Checks / web (push) Failing after 19s
Private ROM checks / private-rom (push) Failing after 25m36s

PRET's crystal and crystal_au builds reproduce both retail cartridges,
and every label the profile pins keeps its address in all three;
check:reference now verifies that when the rebuilds are present. The
revision comes from the header. Revision 1.0's Battle Tower draws only
the first 21 trainers and picks its texts' gender from the name's sixth
character, checked against each cartridge. A native backup imports
across the three, which share every map, event and object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-17 16:12:00 +02:00
1 parent acdaf62eaa
commit 3c480a28f0
17 files changed
+317 -26

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ describe("ROM validation", () => {
bytes[0x14c] = 1;
await expect(
validateRom(new File([bytes], "Pokemon Crystal EU 1.1.gbc")),
).rejects.toThrow("not the supported");
).rejects.toThrow("not a supported English Pokémon Crystal");
});
});
+3 -4
View File
@@ -17,7 +17,7 @@ import { readMovement } from "../src/game/scripts/movement";
import { defaultTextContext, readText } from "../src/game/scripts/text";
import { settle, walkTo, withoutPlayTime } from "./world-driver";
import { drawTile } from "../src/game/rendering/pixels";
import { CRYSTAL_ROM } from "../src/rom/validate";
import { supportedRom } from "../src/rom/validate";
import { VECTORS, type Direction } from "../src/game/world/types";
describe("native ROM formats", () => {
@@ -159,9 +159,8 @@ describe.skipIf(!existsSync(romPath))(
() => {
const load = () => {
const bytes = readFileSync(romPath);
expect(createHash("sha1").update(bytes).digest("hex")).toBe(
CRYSTAL_ROM.sha1,
);
// Any of the supported English cartridges (CRYSTAL_ROM_PATH).
expect(supportedRom(createHash("sha1").update(bytes).digest("hex"))).toBeDefined();
return new CrystalAssets(new Uint8Array(bytes));
};
it("decodes every supported map, tileset, event table and sprite", () => {
+10 -4
View File
@@ -7,6 +7,12 @@ export const romPath = resolve(
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
export const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
/** PRET's rebuilds of the other English cartridges (`make crystal
* crystal_au`), which keep 1.1's addresses for every label the port reads. */
export const revisionRoms = {
"1.0": resolve(".reference/pokecrystal/pokecrystal.gbc"),
au: resolve(".reference/pokecrystal/pokecrystal_au.gbc"),
} as const;
/** Banked addresses from the private PRET rebuild's symbol file. */
export function loadSymbols() {
@@ -34,9 +40,9 @@ export function loadSymbols() {
};
}
/** 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) {
/** Open the reference page and hand it the ROM (by default the user's 1.1)
* through an isolated file input, without an HTTP ROM endpoint. */
export async function openReference(page: Page, rom = romPath) {
await page.goto("/reference.html");
await page.evaluate(() => {
const input = document.createElement("input");
@@ -44,5 +50,5 @@ export async function openReference(page: Page) {
input.id = "reference-fixture";
document.body.append(input);
});
await page.locator("#reference-fixture").setInputFiles(romPath);
await page.locator("#reference-fixture").setInputFiles(rom);
}
+92
View File
@@ -0,0 +1,92 @@
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.");
/** 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);
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { romRevision } from "../src/game/rom/revision";
import { CRYSTAL_ROMS, validateRom } from "../src/rom/validate";
import { emptyBattleTowerState, loadOpponent } from "../src/game/scripts/battle-tower";
import type { SpecialContext } from "../src/game/scripts/special-context";
/** The user's 1.1 cartridge and PRET's rebuilds of the other English ones
* (`make crystal crystal_au` in .reference/pokecrystal), each skipped when
* absent. */
const cartridges = [
{ revision: "1.1", path: process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc", rom: CRYSTAL_ROMS[0] },
{ revision: "1.0", path: ".reference/pokecrystal/pokecrystal.gbc", rom: CRYSTAL_ROMS[1] },
{ revision: "au", path: ".reference/pokecrystal/pokecrystal_au.gbc", rom: CRYSTAL_ROMS[2] },
] as const;
for (const { revision, path, rom } of cartridges)
describe.skipIf(!existsSync(path))(`the ${revision} cartridge`, () => {
const bytes = existsSync(path) ? new Uint8Array(readFileSync(path)) : new Uint8Array();
it("is accepted under its name and recognised from its header", async () => {
// CRYSTAL_ROM_PATH may point the 1.1 entry at another cartridge.
const assets = new CrystalAssets(bytes);
const own = cartridges.find((c) => c.revision === romRevision(assets.reader))!;
expect(await validateRom(new File([bytes], "crystal.gbc"))).toMatchObject({ sha1: own.rom.sha1, name: own.rom.name });
if (revision !== "1.1" || !process.env.CRYSTAL_ROM_PATH) expect(own.revision).toBe(revision);
// One changed byte is a modified ROM.
const changed = bytes.slice();
changed[0x7fff] ^= 1;
await expect(validateRom(new File([changed], "crystal.gbc"))).rejects.toThrow("not a supported English");
});
it("draws Battle Tower trainers from all 70, or from the first 21 on 1.0", () => {
const assets = new CrystalAssets(bytes);
let seed = 84;
const context = {
reader: assets.reader,
data: assets.pokemon,
random: () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
text: () => ({ player: "KAWA", rival: "SILVER", mom: "MOM", strings: {}, weekday: "SUNDAY" }),
} as unknown as SpecialContext;
const drawn = new Set<number>();
for (let draw = 0; draw < 2000; draw++) {
const bt = { ...emptyBattleTowerState(), levelGroup: 1 + (draw % 10) };
drawn.add(loadOpponent(context, bt).trainer);
}
const limit = romRevision(assets.reader) === "1.0" ? 21 : 70;
expect(Math.max(...drawn)).toBe(limit - 1);
expect(drawn.size).toBe(limit);
});
});
+15
View File
@@ -144,6 +144,21 @@ test("save slots: migration, new game, rename, continue, duplicate, delete and i
await preview.getByRole("button", { name: "Close" }).click();
await expect(slots).toHaveCount(2);
// One from another supported English cartridge carries over.
const us10 = testInfo.outputPath("us10.crystal-save.json");
writeFileSync(
us10,
JSON.stringify({
...JSON.parse(readFileSync(backup, "utf8")),
romHash: "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133",
}),
);
await page.locator("#save-file").setInputFiles(us10);
await expect(preview).toContainText("Made with English US/Europe 1.0; it continues with this one");
await expect(page.locator("#import-new")).toHaveCount(1);
await preview.getByRole("button", { name: "Cancel" }).click();
await expect(slots).toHaveCount(2);
// Importing as a new save adds a slot and continues it.
await page.locator("#save-file").setInputFiles(backup);
await page.locator("#import-new").click();
+17
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import "fake-indexeddb/auto";
import { SlotSession, type SlotRuntime } from "../src/app/slot-session";
import { encodeSave, type SaveRecord } from "../src/storage/saves";
import { CRYSTAL_ROMS } from "../src/rom/validate";
import {
SlotRepository,
recordChecksum,
@@ -411,6 +412,22 @@ describe("save slot import", () => {
expect(runtime.game.steps).toBe(2);
});
it("carries a backup over from another supported English cartridge", async () => {
const [us11, us10] = CRYSTAL_ROMS;
const context = setup();
await context.session.load({ ...ROM, sha1: us11.sha1, name: us11.name }, canvas);
const backup = record({ player: "KRIS", id: 5, steps: 3 });
const preview = await context.session.previewImport(encodeSave({ ...backup, romHash: us10.sha1 }));
if (!preview.ok) throw new Error(preview.reason);
expect(preview.madeWith).toBe(us10.name);
expect(preview.record.romHash).toBe(us11.sha1);
const added = await context.session.importBackup(preview, "new");
expect(added.romHash).toBe(us11.sha1);
// The same file under its own cartridge has nothing to carry over.
const own = await context.session.previewImport(encodeSave({ ...backup, romHash: us11.sha1 }));
expect(own.ok && own.madeWith).toBeUndefined();
});
it("previews conflicts and imports as a new slot or over one", async () => {
const { store, runtime, session } = await loaded();
const first = session.active!;