Open the League: Victory Road, the Elite Four, the Hall of Fame and the credits (#33)
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>
This commit is contained in:
1 parent
b6fdc23508
commit
f05fd8ebee
23 files changed
+1943
-107
No files matched your search
@@ -0,0 +1,183 @@
|
||||
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/reference.");
|
||||
|
||||
/** The cartridge's AnimateHallOfFame.DisplayNewHallOfFamer and
|
||||
* HOF_AnimatePlayerPic run with the LCD off (so graphics load at once) and
|
||||
* only their frame waits, HOF_AnimateFrontpic and ProfOaksPCRating
|
||||
* stubbed; the tilemap, attributes, BG palette 0 and the tiles each screen
|
||||
* shows are compared with the native screens. */
|
||||
test("the Hall of Fame ceremony's pages match the cartridge's tilemaps, tiles and palettes", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const { at, routine } = loadSymbols();
|
||||
const flat = (name: string) => {
|
||||
const { bank, address } = routine(name);
|
||||
return address < 0x4000 ? address : bank * 0x4000 + address - 0x4000;
|
||||
};
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
cgb: at("hCGB"),
|
||||
tilemap: at("wTilemap"),
|
||||
attrs: at("wAttrmap"),
|
||||
palettes: at("wBGPals1"),
|
||||
frame: at("wTextboxFrame"),
|
||||
temp: at("wHallOfFameTempMon1"),
|
||||
name: at("wPlayerName"),
|
||||
id: at("wPlayerID"),
|
||||
hours: at("wGameTimeHours"),
|
||||
minutes: at("wGameTimeMinutes"),
|
||||
gender: at("wPlayerGender"),
|
||||
layout: at("GetSGBLayout"),
|
||||
setupFlags: at("wPlayerSpriteSetupFlags"),
|
||||
species: at("wCurPartySpecies"),
|
||||
tempDVs: at("wTempMonDVs"),
|
||||
};
|
||||
const routines = {
|
||||
font: routine("_LoadStandardFont"),
|
||||
extra: routine("LoadFontsBattleExtra"),
|
||||
famer: routine("AnimateHallOfFame.DisplayNewHallOfFamer"),
|
||||
player: routine("HOF_AnimatePlayerPic"),
|
||||
};
|
||||
const skip = ["WaitBGMap", "WaitBGMap2", "DelayFrame", "DelayFrames", "HOF_AnimateFrontpic", "ProfOaksPCRating"].map(flat);
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(
|
||||
async ({ a, routines, skip, get2bpp, request2bpp }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { newHallOfFamerScreen, hallOfFamePlayerCard } = await import("/src/game/scripts/league.ts");
|
||||
const { textTiles } = await import("/src/game/rom/font.ts");
|
||||
const { rgb } = await import("/src/game/scripts/screen.ts");
|
||||
const rom = new Uint8Array(
|
||||
await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer(),
|
||||
);
|
||||
const assets = new CrystalAssets(rom);
|
||||
for (const p of skip) rom[p] = 0xc9;
|
||||
// Request2bpp waits for VBlank even with the LCD off; Get2bpp copies
|
||||
// at once then, from the same arguments.
|
||||
rom.set([0xc3, get2bpp & 255, get2bpp >> 8], request2bpp);
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(rom, document.createElement("canvas"));
|
||||
const write = (p: number, n: number) => reference.writeMemory(p, n & 255);
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const call = (r: { bank: number; address: number }) => [
|
||||
0x3e, r.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
|
||||
0xcd, r.address & 255, r.address >> 8,
|
||||
];
|
||||
/** Run the font loaders, the routine (HL = `hl`), then a sentinel;
|
||||
* with `layout`, GetSGBLayout's SCGB_PLAYER_OR_MON_FRONTPIC_PALS
|
||||
* first, as AnimateHOFMonEntrance applies it for the same Pokémon
|
||||
* before DisplayNewHallOfFamer. */
|
||||
const run = (target: { bank: number; address: number }, hl = 0, layout = false) => {
|
||||
write(0xff70, 1);
|
||||
write(0xff40, 0);
|
||||
write(a.cgb, 1);
|
||||
const program = [
|
||||
0xf3, 0x31, 0xfe, 0xc7,
|
||||
...call(routines.font),
|
||||
...call(routines.extra),
|
||||
...(layout ? [0x06, 0x1a, 0xcd, a.layout & 255, a.layout >> 8] : []),
|
||||
0x21, hl & 255, hl >> 8,
|
||||
...call(target),
|
||||
0x3e, 0x42, 0xea, 0xf0, 0xc0,
|
||||
0x18, 0xfe,
|
||||
];
|
||||
write(0xc0f0, 0);
|
||||
program.forEach((value, offset) => write(0xc000 + offset, value));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 200 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
return read(0xc0f0) === 0x42;
|
||||
};
|
||||
type Screen = { map: number[]; attrs: number[]; palettes: number[][][]; tiles: Uint8Array };
|
||||
const compare = (label: unknown, native: Screen, tiles: number[]) => {
|
||||
const map = Array.from({ length: 360 }, (_, i) => read(a.tilemap + i));
|
||||
// The attributes shown: WaitBGMap copies tiles only, so the text
|
||||
// boxes' palette in wAttrmap stays off screen; GetSGBLayout's
|
||||
// ApplyAttrmap set VRAM bank 1.
|
||||
write(0xff4f, 1);
|
||||
const attrs = Array.from({ length: 360 }, (_, i) => read(0x9800 + Math.floor(i / 20) * 32 + (i % 20)));
|
||||
write(0xff4f, 0);
|
||||
write(0xff70, 5);
|
||||
const palette = Array.from({ length: 4 }, (_, i) =>
|
||||
rgb(read(a.palettes + i * 2) | (read(a.palettes + i * 2 + 1) << 8)),
|
||||
);
|
||||
write(0xff70, 1);
|
||||
write(0xff4f, 0);
|
||||
const tileDiff = tiles.flatMap((tile) =>
|
||||
Array.from({ length: 16 }, (_, byte) => {
|
||||
const actual = read((tile < 128 ? 0x9000 : 0x8800) + (tile % 128) * 16 + byte);
|
||||
return actual === native.tiles[tile * 16 + byte] ? [] : [{ tile, byte, actual, expected: native.tiles[tile * 16 + byte] }];
|
||||
}).flat(),
|
||||
);
|
||||
const mapDiff = map.flatMap((tile, i) => (tile === native.map[i] ? [] : [{ i, original: tile, native: native.map[i] }]));
|
||||
const attrDiff = attrs.filter((v, i) => v !== native.attrs[i]).length;
|
||||
const paletteDiff = JSON.stringify(palette) !== JSON.stringify(native.palettes[0]);
|
||||
return mapDiff.length || attrDiff || paletteDiff || tileDiff.length
|
||||
? [{ label, mapDiff: mapDiff.slice(0, 10), attrDiff, palette, nativePalette: native.palettes[0], tileDiff: tileDiff.slice(0, 5) }]
|
||||
: [];
|
||||
};
|
||||
const failures: unknown[] = [];
|
||||
const mons = [
|
||||
{ species: 25, dvs: 0xaaaa, level: 42, nickname: "SPARK", otId: 12345 },
|
||||
{ species: 201, dvs: 0xffff, level: 100, nickname: "UNOWN", otId: 0 },
|
||||
{ species: 29, dvs: 0, level: 5, nickname: "NIDORAN♀", otId: 65535 },
|
||||
{ species: 157, dvs: 0xfaaa, level: 64, nickname: "TYPHLOSION", otId: 35209 },
|
||||
...Array.from({ length: 251 }, (_, i) => ({
|
||||
species: i + 1,
|
||||
dvs: i % 3 ? 0x2aaa : 0xffff,
|
||||
level: 1 + (i % 100),
|
||||
nickname: "INDUCTED",
|
||||
otId: i * 257,
|
||||
})),
|
||||
];
|
||||
for (const [index, mon] of mons.entries()) {
|
||||
const record = [
|
||||
mon.species, mon.otId >> 8, mon.otId & 255, mon.dvs >> 8, mon.dvs & 255, mon.level,
|
||||
...textTiles(mon.nickname), ...Array(10 - mon.nickname.length).fill(0x50),
|
||||
];
|
||||
record.forEach((value, offset) => write(a.temp + offset, value));
|
||||
write(a.species, mon.species);
|
||||
write(a.tempDVs, mon.dvs >> 8);
|
||||
write(a.tempDVs + 1, mon.dvs & 255);
|
||||
write(a.frame, index % 8);
|
||||
const returned = run(routines.famer, a.temp, true);
|
||||
const native = newHallOfFamerScreen(assets.pokemon, mon, index % 8);
|
||||
const tiles = [...new Set(native.map)];
|
||||
failures.push(...(returned ? compare(mon, native, tiles) : [{ mon, returned }]));
|
||||
}
|
||||
const players = [
|
||||
{ name: "KAWA", id: 35209, female: false, time: { hours: 1, minutes: 23 } },
|
||||
{ name: "KRIS", id: 0, female: true, time: { hours: 999, minutes: 59 } },
|
||||
{ name: "ABCDEFG", id: 65535, female: false, time: { hours: 0, minutes: 0 } },
|
||||
{ name: "ᴾᴷᴹᴺ", id: 42, female: true, time: { hours: 42, minutes: 7 } },
|
||||
];
|
||||
for (const [index, player] of players.entries()) {
|
||||
write(0xff70, 1);
|
||||
const name = [...textTiles(player.name), 0x50];
|
||||
name.forEach((value, offset) => write(a.name + offset, value));
|
||||
write(a.id, player.id >> 8);
|
||||
write(a.id + 1, player.id & 255);
|
||||
write(a.hours, player.time.hours >> 8);
|
||||
write(a.hours + 1, player.time.hours & 255);
|
||||
write(a.minutes, player.time.minutes);
|
||||
write(a.gender, player.female ? 1 : 0);
|
||||
write(a.setupFlags, 0);
|
||||
write(a.frame, index % 8);
|
||||
const returned = run(routines.player);
|
||||
const native = hallOfFamePlayerCard(
|
||||
assets.pokemon,
|
||||
{ ...player, time: { ...player.time, seconds: 0, frames: 0, capped: false } },
|
||||
index % 8,
|
||||
);
|
||||
// The card's tiles, and the back picture loaded from $31.
|
||||
const tiles = [...new Set([...native.map, ...Array.from({ length: 36 }, (_, i) => 0x31 + i)])];
|
||||
failures.push(...(returned ? compare(player, native, tiles) : [{ player, returned }]));
|
||||
}
|
||||
reference.dispose();
|
||||
return { mons: mons.length, failures };
|
||||
},
|
||||
{ a, routines, skip, get2bpp: at("Get2bpp"), request2bpp: flat("Request2bpp") },
|
||||
);
|
||||
expect(result.failures, JSON.stringify(result.failures.slice(0, 3))).toEqual([]);
|
||||
});
|
||||
Reference in new issue
Block a user