Routes 26 and 27 with their houses, Tohjo Falls, the Victory Road gate, Victory Road, Route 23, Indigo Plateau, the Elite Four's rooms, Lance's room and the Hall of Fame are open. - halloffame (game/scripts/league.ts): the music fades as SAVING RECORD shows, the win and the team (without Eggs) are recorded, each Pokémon's back picture passes and its front picture slides in for its "New Hall of Famer!" page and cry, then the player's card with the play time and Professor Oak's rating; the play time stops meanwhile. The world then continues at New Bark as Continue does after the cartridge's reset. - credits and the credits after the Hall of Fame (CreditsMachine): the ROM's script, strings, banner Pokémon and THE END, frame by frame as VBlank_Credits presents them, with the frames each command group runs over measured on the cartridge. B shortens credits seen before. - wait, appear/disappear of the player, and rival names in battle texts (PlaceEnemysName prints wRivalName alone: "???" before he is named). - The tile-screen renderer wraps hSCX/hSCY at 256 pixels and gives rows their own hSCX. - Reference: the credits match the cartridge on every frame (BG map, banner tiles, CRAM palettes, LY overrides, parser), with and without B; the ceremony's pages match for every species and the player's card for Chris and Kris. - Playthrough: five stages from Tin Tower to the Hall of Fame (HM07 from Ice Path, Tohjo Falls' waterfall, Victory Road's ladders, the Elite Four and Lance); the driver follows warps within a map and fights carefully from here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
8.7 KiB
TypeScript
185 lines
8.7 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
|
import { existsSync } from "node:fs";
|
|
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
|
|
|
|
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols.");
|
|
|
|
/** The cartridge's own InitDisplayForRedCredits and Credits run with their
|
|
* interrupts after the game has booted; each video frame the screen's
|
|
* background map, the banner's 16 tiles, wBGPals2's three palettes,
|
|
* wLYOverrides and the parser's position and timer are compared with
|
|
* CreditsMachine's, from the first parse to well past THE END. Without B
|
|
* and with B held (which only shortens credits seen before). */
|
|
for (const skip of [false, true])
|
|
test(`Credits match the cartridge frame by frame${skip ? " with B held" : ""}`, async ({ page }) => {
|
|
test.setTimeout(300_000);
|
|
const { at, routine } = loadSymbols();
|
|
const a = {
|
|
romBank: at("hROMBank"),
|
|
pos: at("wCreditsPos"),
|
|
timer: at("wCreditsTimer"),
|
|
bgPals2: at("wBGPals2"),
|
|
lyOverrides: at("wLYOverrides"),
|
|
program: at("wOverworldMapBlocks"),
|
|
stackTop: at("wStackTop"),
|
|
};
|
|
const init = routine("InitDisplayForRedCredits"),
|
|
credits = routine("Credits");
|
|
await openReference(page);
|
|
const result = await page.evaluate(
|
|
async ({ a, init, credits, skip }) => {
|
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
|
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
|
const { CreditsMachine } = await import("/src/game/scripts/league.ts");
|
|
const { hallOfFameTiles } = await import("/src/game/scripts/hall-of-fame.ts");
|
|
const rom = new Uint8Array(
|
|
await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer(),
|
|
);
|
|
const assets = new CrystalAssets(rom);
|
|
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);
|
|
// Boot to the intro so the interrupt handlers and hardware are set up.
|
|
reference.advanceFrames(600);
|
|
// DI; SP=wStackTop; EI; InitDisplayForRedCredits; B = the status
|
|
// flags (bit 6: the Hall of Fame); Credits; a sentinel.
|
|
const bank = (n: number) => [0x3e, n, 0xe0, a.romBank & 255, 0xea, 0, 0x20];
|
|
const sentinel = a.program + 0x80;
|
|
const program = [
|
|
0xf3,
|
|
0x31, a.stackTop & 255, a.stackTop >> 8,
|
|
0xfb,
|
|
...bank(init.bank),
|
|
0xcd, init.address & 255, init.address >> 8,
|
|
...bank(credits.bank),
|
|
0x06, skip ? 0x40 : 0,
|
|
0xcd, credits.address & 255, credits.address >> 8,
|
|
0x3e, 0x42, 0xea, sentinel & 255, sentinel >> 8,
|
|
0x18, 0xfe,
|
|
];
|
|
write(sentinel, 0);
|
|
program.forEach((v, i) => write(a.program + i, v));
|
|
reference.setProgramCounter(a.program);
|
|
if (skip) reference.setButton("b", true);
|
|
const machine = new CreditsMachine(assets.reader, hallOfFameTiles(assets.pokemon, 0), skip);
|
|
type State = { map: number[]; tiles: number[]; pals: number[]; ly: number[]; pos: number; timer: number; lcd?: number[] };
|
|
// The parser's work RAM at line 143 (the frame's logic has run,
|
|
// its VBlank has not), then what that VBlank presents at line 152:
|
|
// the BG map, the banner's tiles and the palettes in CRAM.
|
|
const toLine = (line: number) => {
|
|
for (let guard = 0; read(0xff44) !== line; guard++) {
|
|
if (guard > 20000) throw new Error("The LCD does not reach the line.");
|
|
reference.advanceCycles(16);
|
|
}
|
|
};
|
|
const sample = (): State => {
|
|
toLine(143);
|
|
const wram = {
|
|
ly: [read(a.lyOverrides + 0x1f), read(a.lyOverrides + 0x87)],
|
|
pos: read(a.pos) | (read(a.pos + 1) << 8),
|
|
timer: read(a.timer),
|
|
};
|
|
toLine(152);
|
|
const cram = Array.from({ length: 12 }, (_, i) => {
|
|
write(0xff68, i * 2);
|
|
const low = read(0xff69);
|
|
write(0xff68, i * 2 + 1);
|
|
return low | (read(0xff69) << 8);
|
|
});
|
|
const state = {
|
|
map: Array.from({ length: 360 }, (_, i) => read(0x9800 + Math.floor(i / 20) * 32 + (i % 20))),
|
|
tiles: Array.from({ length: 256 }, (_, i) => read(0x9000 + i)),
|
|
pals: cram,
|
|
...wram,
|
|
lcd: [read(0xff44), read(0xff41) & 3],
|
|
};
|
|
reference.advanceCycles(456 * 140);
|
|
return state;
|
|
};
|
|
const native = (): State => {
|
|
const d = machine.debug();
|
|
return {
|
|
map: [...machine.shown],
|
|
tiles: Array.from(machine.vram.subarray(0, 256)),
|
|
pals: machine.paletteWords(),
|
|
ly: [d.lyOverride, d.lyOverride],
|
|
pos: d.pos,
|
|
timer: d.timer,
|
|
};
|
|
};
|
|
// Sample once a frame at the last visible line: the frame's logic
|
|
// has run and its VBlank has not.
|
|
let guard = 0;
|
|
while (read(a.pos) === 0 && read(a.pos + 1) === 0 && guard++ < 300) reference.advanceFrames(1);
|
|
while (read(0xff44) !== 143 && guard++ < 100000) reference.advanceCycles(16);
|
|
const original: State[] = [];
|
|
for (let frame = 0; frame < 12000 && read(sentinel) !== 0x42; frame++) original.push(sample());
|
|
const nativeFrames: State[] = [];
|
|
const present = () => {
|
|
const before = native();
|
|
machine.vblank();
|
|
const after = native();
|
|
nativeFrames.push({ ...after, ly: before.ly, pos: before.pos, timer: before.timer });
|
|
};
|
|
for (let frame = 0; frame < original.length + 8; frame++) {
|
|
const logic = machine.step(skip ? "b" : "");
|
|
let next = logic.next();
|
|
while (!next.done) {
|
|
present();
|
|
next = logic.next();
|
|
}
|
|
present();
|
|
}
|
|
// Positions between two waits are a parse in progress, whose own
|
|
// progress the native parser does not model: compare the parser
|
|
// where it rests, and the screen always.
|
|
const { KANTO } = await import("/src/game/rom/profile.ts");
|
|
const boundaries = new Set<number>([0]);
|
|
for (let p = 0, guard = 0; guard < 1024; guard++) {
|
|
const command = rom[KANTO.CreditsScript + p++];
|
|
if (command === 0xff) {
|
|
boundaries.add(p);
|
|
break;
|
|
}
|
|
if (command === 0xfe || command === 0xfa) boundaries.add(++p);
|
|
else if (command === 0xfd || command < 0xf9) p++;
|
|
}
|
|
const rest = ({ lcd: _, ...x }: State) => (boundaries.has(x.pos) ? x : { ...x, pos: -1, timer: -1 });
|
|
const same = (x: State, y: State) => JSON.stringify(rest(x)) === JSON.stringify(boundaries.has(x.pos) ? y : rest(y));
|
|
// The first emulator sample falls after the first parse; find its
|
|
// native frame, then every later frame must match in order.
|
|
const offset = nativeFrames.findIndex((frame) => frame.pos === original[0].pos && same(original[0], frame));
|
|
const mismatches: unknown[] = [];
|
|
if (offset >= 0)
|
|
original.forEach((frame, i) => {
|
|
const mine = nativeFrames[offset + i];
|
|
if (mismatches.length >= 5 || (mine && same(frame, mine))) return;
|
|
if (!mine) return void mismatches.push({ frame: i, native: "missing" });
|
|
const diff: Record<string, unknown> = { frame: i, pos: frame.pos, timer: [frame.timer, mine.timer], lcd: frame.lcd };
|
|
for (const key of ["map", "tiles", "pals", "ly"] as const) {
|
|
const cells = frame[key].flatMap((value, index) =>
|
|
value === mine[key][index] ? [] : [`${index}:${value}/${mine[key][index]}`],
|
|
);
|
|
if (cells.length) diff[key] = cells.slice(0, 12).join(" ");
|
|
}
|
|
if (frame.pos !== mine.pos) diff.nativePos = mine.pos;
|
|
mismatches.push(diff);
|
|
});
|
|
return {
|
|
frames: original.length,
|
|
offset,
|
|
first: original[0] && { pos: original[0].pos, timer: original[0].timer },
|
|
debugFirst: offset < 0 ? [original[0], ...nativeFrames.slice(0, 4)].map((x) => ({ ...x, map: x.map.slice(80, 140).join(","), tiles: x.tiles.slice(0, 16).join(",") })) : undefined,
|
|
endPos: original.at(-1)?.pos,
|
|
mismatches,
|
|
};
|
|
},
|
|
{ a, init, credits, skip },
|
|
);
|
|
expect(result.offset, JSON.stringify(result.debugFirst)).toBeGreaterThanOrEqual(0);
|
|
expect(result.frames).toBeGreaterThan(skip ? 3000 : 8000);
|
|
expect(result.mismatches).toEqual([]);
|
|
});
|