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
@@ -168,6 +168,26 @@ describe.skipIf(!existsSync(romPath) || !process.env.MAKE_TEST_SAVES)(
|
||||
write("egg-hatching", world);
|
||||
});
|
||||
|
||||
it("writes hall-of-fame-ceremony: the Hall of Fame's entrance, where Lance's scene and the ceremony start", () => {
|
||||
const a = assets();
|
||||
const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00"));
|
||||
world.state.flags.add(ENGINE_FLAGS.pokedex);
|
||||
for (let badge = 0x1b; badge < 0x1b + 8; badge++) world.state.flags.add(badge);
|
||||
world.state.party = [
|
||||
caught(a, world, 157, 60, 0x9abc), // TYPHLOSION
|
||||
caught(a, world, 25, 40, 0x2aaa), // PIKACHU, shiny
|
||||
caught(a, world, 201, 40, 0xffff), // UNOWN
|
||||
{ ...caught(a, world, 175, 5), egg: true }, // an Egg, left out
|
||||
];
|
||||
for (const species of [157, 25, 201]) {
|
||||
world.state.pokedex.seen.add(species);
|
||||
world.state.pokedex.caught.add(species);
|
||||
}
|
||||
// The Hall of Fame's own warp; continuing runs HallOfFameEnterScene.
|
||||
world.restore({ ...world.save(), map: "16:8", x: 4, y: 13, direction: "up", objects: [] });
|
||||
write("hall-of-fame-ceremony", world);
|
||||
});
|
||||
|
||||
it("writes hall-of-fame: a Pokémon Center whose PC has three teams", () => {
|
||||
const a = assets();
|
||||
const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00"));
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { CreditsMachine, textboxPages } from "../src/game/scripts/league";
|
||||
import { hallOfFameTiles } from "../src/game/scripts/hall-of-fame";
|
||||
import { PC, RAM } from "../src/game/rom/profile";
|
||||
import { tileScreenBackground } from "../src/game/rendering/tile-screen";
|
||||
import { blankScreen } from "../src/game/scripts/screen";
|
||||
import { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
|
||||
describe("tile screens scrolled like the background map", () => {
|
||||
// Tile 1 is solid color 3, tile 0 color 0.
|
||||
const tiles = new Uint8Array(2 * 16);
|
||||
tiles.fill(0xff, 16);
|
||||
const screen = () => {
|
||||
const s = blankScreen(tiles, "t", [[[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]], "");
|
||||
s.map[0] = 1; // the top-left tile
|
||||
return s;
|
||||
};
|
||||
it("wraps hSCX at 256 pixels, with `fill` beyond the tilemap", () => {
|
||||
const s = screen();
|
||||
s.scroll = { x: 256 - 8, y: 0, fill: 0 };
|
||||
const { indices } = tileScreenBackground(s);
|
||||
// The tile moves right by one tile; the column scrolled in is fill.
|
||||
expect(indices[0]).toBe(0);
|
||||
expect(indices[8]).toBe(3);
|
||||
expect(indices[16]).toBe(0);
|
||||
});
|
||||
it("gives a row its own hSCX and background tiles", () => {
|
||||
const s = screen();
|
||||
s.map[4 * 20] = 1;
|
||||
s.rowScroll = [{ row: 4, x: 0xfe, tiles: Array.from({ length: 32 }, (_, x) => (x === 31 ? 1 : 0)) }];
|
||||
const { indices } = tileScreenBackground(s);
|
||||
// Row 4 shows its 32nd tile's last two columns, then its own tiles.
|
||||
expect(indices[32 * 160]).toBe(3);
|
||||
expect(indices[32 * 160 + 2]).toBe(0);
|
||||
// Row 0 is not scrolled.
|
||||
expect(indices[0]).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("the League with the local ROM", () => {
|
||||
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||
const ROUTE_27 = "24:2",
|
||||
VICTORY_ROAD_GATE = "23:13",
|
||||
HALL_OF_FAME = "16:8",
|
||||
NEW_BARK = "24:4";
|
||||
const world = () => {
|
||||
let seed = 21;
|
||||
return new GameWorld(
|
||||
new CrystalAssets(bytes),
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
() => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
},
|
||||
);
|
||||
};
|
||||
const mon = (game: GameWorld, species: number, level = 50) =>
|
||||
generatePartyMon(game.assets.pokemon, {
|
||||
species,
|
||||
level,
|
||||
item: 0,
|
||||
otId: game.playerId,
|
||||
ot: "KAWA",
|
||||
dvs: 0x8888,
|
||||
caughtData: 0,
|
||||
});
|
||||
/** Run the world to the end of its scripts, pressing A on any screen
|
||||
* that waits, and count the ticks the credits' screens take. */
|
||||
const drive = (game: GameWorld, b = false) => {
|
||||
const shown: string[] = [];
|
||||
let screens = 0,
|
||||
still = 0,
|
||||
label = "";
|
||||
for (let tick = 0; tick < 60_000; tick++) {
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
const dialogue = game.dialogue;
|
||||
if (dialogue?.choice?.kind === "screen") {
|
||||
screens++;
|
||||
const now = dialogue.choice.screen.label;
|
||||
if (now !== label) [label, still] = [now, 0];
|
||||
if (!shown.includes(now)) shown.push(now);
|
||||
// B on the credits' pages only (the rating takes B as well as A).
|
||||
if (b && /, /.test(now) && !now.includes("play time")) game.bufferScreenButton("b");
|
||||
if (++still > 60) {
|
||||
game.bufferScreenButton("a");
|
||||
still = 0;
|
||||
}
|
||||
game.tick();
|
||||
continue;
|
||||
}
|
||||
if (dialogue) {
|
||||
const last = dialogue.index === dialogue.pages.length - 1;
|
||||
if (!shown.includes(dialogue.pages[dialogue.index])) shown.push(dialogue.pages[dialogue.index]);
|
||||
if (last && dialogue.choice) game.choose(0);
|
||||
else if (last && dialogue.holdTicks !== undefined) game.tick();
|
||||
else game.advanceDialogue();
|
||||
continue;
|
||||
}
|
||||
if (!game.busy) {
|
||||
game.tick();
|
||||
if (!game.busy) return { shown, screens };
|
||||
continue;
|
||||
}
|
||||
game.tick();
|
||||
}
|
||||
throw new Error("The world did not settle.");
|
||||
};
|
||||
|
||||
it("has Route 27's fisher stop a first step into Kanto", () => {
|
||||
const game = world();
|
||||
game.warpTo(ROUTE_27, 18, 11);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x: 18, y: 11, direction: "up" });
|
||||
expect(game.move("up")).toBe(true);
|
||||
const shown = settle(game).join(" ");
|
||||
expect(shown).toContain("Hey!");
|
||||
expect(shown).toContain("Do you know what\nyou just did?");
|
||||
expect(game.state.scenes[ROUTE_27]).toBe(1); // SCENE_ROUTE27_NOOP
|
||||
});
|
||||
|
||||
it("turns back a trainer without the eight Johto badges at the Victory Road gate", () => {
|
||||
const game = world();
|
||||
game.warpTo(VICTORY_ROAD_GATE, 10, 12);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x: 10, y: 12, direction: "up" });
|
||||
expect(game.move("up")).toBe(true);
|
||||
const shown = settle(game).join(" ");
|
||||
expect(shown).toContain("You don't have all\nthe GYM BADGES of\nJOHTO.");
|
||||
expect({ x: game.x, y: game.y }).toEqual({ x: 10, y: 12 });
|
||||
for (let badge = 0x1b; badge < 0x1b + 8; badge++) game.state.flags.add(badge);
|
||||
game.restore({ ...game.save(), x: 10, y: 12, direction: "up" });
|
||||
expect(game.move("up")).toBe(true);
|
||||
expect(settle(game).join(" ")).toContain("BADGES of JOHTO!");
|
||||
game.restore({ ...game.save(), x: 10, y: 10, direction: "down" });
|
||||
expect(game.move("down")).toBe(true);
|
||||
expect(settle(game)).toEqual([]);
|
||||
});
|
||||
|
||||
it("records the team without its Egg, keeps the play time still and wakes in New Bark", () => {
|
||||
const game = world();
|
||||
const egg = { ...mon(game, 175, 5), egg: true };
|
||||
game.state.party = [mon(game, 157, 60), egg, mon(game, 147, 30)];
|
||||
game.warpTo(HALL_OF_FAME, 4, 13);
|
||||
let before: { hours: number; minutes: number; seconds: number } | undefined;
|
||||
const { shown } = (() => {
|
||||
// Note the play time when the ceremony's first screen appears.
|
||||
for (let tick = 0; tick < 2000 && !before; tick++) {
|
||||
if (game.dialogue?.choice?.kind === "screen") before = { ...game.gameTime };
|
||||
else if (game.dialogue) game.advanceDialogue();
|
||||
else game.tick();
|
||||
}
|
||||
return drive(game);
|
||||
})();
|
||||
expect(before).toBeDefined();
|
||||
expect(shown.join(" ")).toContain("Saving record");
|
||||
expect(shown).toContain("New Hall of Famer!: TYPHLOSION, TYPHLOSION, level 60, ID " + String(game.playerId).padStart(5, "0"));
|
||||
expect(shown.some((label) => label.startsWith("STAFF") || label.includes("CRYSTAL VERSION"))).toBe(true);
|
||||
expect(game.state.hallOfFame).toBe(true);
|
||||
expect(game.state.hallOfFameCount).toBe(1);
|
||||
expect(game.state.hallOfFameTeams).toHaveLength(1);
|
||||
expect(game.state.hallOfFameTeams[0]).toMatchObject({ win: 1, mons: [{ species: 157 }, { species: 147 }] });
|
||||
expect(game.state.party.every((member) => member.egg || member.hp === member.stats[0])).toBe(true);
|
||||
// halloffame clears GAME_TIMER_COUNTING_F for the ceremony and credits.
|
||||
expect({ hours: game.gameTime.hours, minutes: game.gameTime.minutes }).toEqual({
|
||||
hours: before!.hours,
|
||||
minutes: before!.minutes,
|
||||
});
|
||||
expect(game.mapId).toBe(NEW_BARK);
|
||||
expect({ x: game.x, y: game.y }).toEqual({ x: 13, y: 6 });
|
||||
// The ceremony does not play again.
|
||||
game.warpTo(HALL_OF_FAME, 4, 13);
|
||||
expect(drive(game).screens).toBe(0);
|
||||
});
|
||||
|
||||
it("caps the League wins at 200 and keeps the 30 newest teams; B shortens credits seen before", () => {
|
||||
const run = (count: number, b: boolean) => {
|
||||
const game = world();
|
||||
game.state.party = [mon(game, 157, 60)];
|
||||
game.state.hallOfFame = count > 0;
|
||||
game.state.hallOfFameCount = count;
|
||||
game.state.hallOfFameTeams = Array.from({ length: Math.min(30, count) }, (_, i) => ({
|
||||
win: count - i,
|
||||
mons: [{ species: 25, otId: 1, dvs: 0, level: 5, nickname: "PIKACHU" }],
|
||||
}));
|
||||
game.warpTo(HALL_OF_FAME, 4, 13);
|
||||
return { game, ...drive(game, b) };
|
||||
};
|
||||
const capped = run(200, false);
|
||||
expect(capped.game.state.hallOfFameCount).toBe(200);
|
||||
expect(capped.game.state.hallOfFameTeams).toHaveLength(30);
|
||||
expect(capped.game.state.hallOfFameTeams[0]).toMatchObject({ win: 200, mons: [{ species: 157 }] });
|
||||
// The first time, B does nothing; a team that has entered before can
|
||||
// hurry the credits along with it.
|
||||
expect(run(0, true).screens).toBe(run(0, false).screens);
|
||||
expect(run(5, true).screens).toBeLessThan(run(5, false).screens - 1000);
|
||||
});
|
||||
|
||||
describe("Credits", () => {
|
||||
const assets = existsSync(romPath) ? new CrystalAssets(bytes) : undefined;
|
||||
const runCredits = (press: (frame: number) => string) => {
|
||||
const machine = new CreditsMachine(assets!.reader, hallOfFameTiles(assets!.pokemon, 0), false);
|
||||
const labels: string[] = [];
|
||||
for (let frame = 0; frame < 20_000; frame++) {
|
||||
const step = machine.step(press(frame));
|
||||
let next = step.next();
|
||||
while (!next.done) {
|
||||
machine.vblank();
|
||||
next = step.next();
|
||||
}
|
||||
if (next.value) return { frame, labels, machine };
|
||||
const label = machine.screen().label;
|
||||
if (labels.at(-1) !== label) labels.push(label);
|
||||
machine.vblank();
|
||||
}
|
||||
return { frame: -1, labels, machine };
|
||||
};
|
||||
it("roll from the staff to THE END and close only on A after it", () => {
|
||||
// A held throughout ends the credits right after CREDITS_END.
|
||||
const { frame, labels, machine } = runCredits(() => "a");
|
||||
expect(labels).toContain("POKéMON, CRYSTAL VERSION, STAFF");
|
||||
expect(labels).toContain("DIRECTOR, SATOSHI TAJIRI");
|
||||
expect(labels).toContain("EXECUTIVE PRODUCER, HIROSHI YAMAUCHI");
|
||||
expect(frame).toBeGreaterThan(6900);
|
||||
expect(machine.debug().exit).toBe(true);
|
||||
// THE END's 16 tiles at (6, 9) and (6, 10).
|
||||
expect(machine.shown.slice(9 * 20 + 6, 9 * 20 + 14)).toEqual([0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47]);
|
||||
expect(machine.shown.slice(10 * 20 + 6, 10 * 20 + 14)).toEqual([0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f]);
|
||||
});
|
||||
it("scroll the borders two pixels a cycle and animate the banner", () => {
|
||||
const machine = new CreditsMachine(assets!.reader, hallOfFameTiles(assets!.pokemon, 0), false);
|
||||
const scrolls = new Set<number>(),
|
||||
banners = new Set<string>();
|
||||
for (let frame = 0; frame < 800; frame++) {
|
||||
const step = machine.step("");
|
||||
let next = step.next();
|
||||
while (!next.done) {
|
||||
machine.vblank();
|
||||
next = step.next();
|
||||
}
|
||||
const screen = machine.screen();
|
||||
scrolls.add(screen.rowScroll![1].x);
|
||||
banners.add(screen.tileset);
|
||||
machine.vblank();
|
||||
}
|
||||
expect([...scrolls].every((x) => x % 2 === 0)).toBe(true);
|
||||
expect(scrolls.size).toBeGreaterThan(40);
|
||||
expect(banners.size).toBeGreaterThan(4);
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes the rating's text box to tiles, keeping \"'s\" as one tile", () => {
|
||||
const assets = new CrystalAssets(bytes);
|
||||
const ram = (address: number) =>
|
||||
address === RAM.stringBuffer3 ? "42" : address === RAM.stringBuffer4 ? "7" : "";
|
||||
const pages = textboxPages(assets.reader, PC.OakPCText3, ram, "KAWA");
|
||||
expect(pages).toHaveLength(2);
|
||||
// "42 POKéMON seen" / "7 POKéMON owned", then a para.
|
||||
expect(pages[0].wait).toBe(true);
|
||||
expect(pages[0].lines[0].slice(0, 2)).toEqual([0xfa, 0xf8]);
|
||||
expect(pages[0].lines[1][0]).toBe(0xfd);
|
||||
// "PROF.OAK's" / "Rating:" ends the text without a wait.
|
||||
expect(pages[1].wait).toBe(false);
|
||||
expect(pages[1].lines[0]).toContain(0xd4);
|
||||
expect(pages[1].lines[0]).toHaveLength(9);
|
||||
});
|
||||
});
|
||||
@@ -135,8 +135,9 @@ describe.skipIf(!existsSync(romPath))("Cherrygrove's first rival battle with the
|
||||
expect(world.move("right")).toBe(true);
|
||||
const shown = drive(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
// TrainerClassNames' "RIVAL" and Rival1Group's placeholder name "?".
|
||||
expect(shown).toContain("RIVAL ?\nwas defeated!");
|
||||
// PlaceEnemysName prints a rival's wRivalName alone: "???" until the
|
||||
// officer asks for it.
|
||||
expect(shown).toContain("???\nwas defeated!");
|
||||
expect(shown).toContain("My name's ???.");
|
||||
expect(world.state.pokedex.seen.has(rivalSpecies)).toBe(true);
|
||||
expect(world.state.money).toBe(money + 300);
|
||||
@@ -154,7 +155,7 @@ describe.skipIf(!existsSync(romPath))("Cherrygrove's first rival battle with the
|
||||
const world = questRivalFixtureWorld(assets);
|
||||
world.restore({ ...world.save(), x: 34, y: 6, direction: "left" });
|
||||
expect(world.move("left")).toBe(true);
|
||||
expect(drive(world)).toContain("RIVAL ?\nwas defeated!");
|
||||
expect(drive(world)).toContain("???\nwas defeated!");
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.state.scenes["26:3"]).toBe(0);
|
||||
expect(world.objects().some((object) => object.id === RIVAL)).toBe(false);
|
||||
|
||||
+17
-11
@@ -520,23 +520,29 @@ describe.skipIf(!existsSync(romPath))(
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
);
|
||||
const start = world.save();
|
||||
// New Bark Town's east edge is water leading to Route 27, which
|
||||
// stays closed: a surfer is refused.
|
||||
// New Bark Town's east edge is water leading to Route 27; with Route
|
||||
// 27 closed for this test, a surfer is refused.
|
||||
const town = world.assets.map("24:4"),
|
||||
right = town.width - 1;
|
||||
const edge = Array.from({ length: town.height }, (_, y) => y).find(
|
||||
(y) =>
|
||||
world.assets.permission(world.assets.collision(town, right, y)) === 1,
|
||||
)!;
|
||||
world.restore({
|
||||
...world.save(),
|
||||
map: "24:4",
|
||||
x: right,
|
||||
y: edge,
|
||||
playerState: 4, // PLAYER_SURF
|
||||
});
|
||||
expect(world.move("right")).toBe(false);
|
||||
expect(world.notice).toBe("Route 27 is not ported yet.");
|
||||
const route27 = NATIVE_MAPS["24:2"];
|
||||
delete NATIVE_MAPS["24:2"];
|
||||
try {
|
||||
world.restore({
|
||||
...world.save(),
|
||||
map: "24:4",
|
||||
x: right,
|
||||
y: edge,
|
||||
playerState: 4, // PLAYER_SURF
|
||||
});
|
||||
expect(world.move("right")).toBe(false);
|
||||
expect(world.notice).toBe("Route 27 is not ported yet.");
|
||||
} finally {
|
||||
NATIVE_MAPS["24:2"] = route27;
|
||||
}
|
||||
world.restore(start);
|
||||
const {
|
||||
scenes,
|
||||
|
||||
@@ -39,6 +39,11 @@ export class Playthrough {
|
||||
/** Plan ledge jumps (Tin Tower, Route 45). Off for the earlier stages,
|
||||
* whose seeded runs were recorded walking around ledges. */
|
||||
ledges = false;
|
||||
/** Fight as a player facing the League would (off for the stages
|
||||
* recorded before it): weigh each move by its accuracy, cure poison,
|
||||
* paralysis and burns with a Full Heal in a trainer battle, and heal
|
||||
* below two thirds of the HP rather than half. */
|
||||
careful = false;
|
||||
seed = 0x2a;
|
||||
private stage = 0;
|
||||
readonly now = () => new Date("2026-09-22T12:00:00");
|
||||
@@ -82,14 +87,18 @@ export class Playthrough {
|
||||
targetTypes,
|
||||
identified: false,
|
||||
}).damage;
|
||||
const weighed = (move: number) =>
|
||||
this.careful ? score(move) * this.assets.pokemon.move(move).accuracy : score(move);
|
||||
let best = -1;
|
||||
mon.moves.forEach((move, i) => {
|
||||
if (!move || !(mon.pp[i] & 63) || i === disabled) return;
|
||||
if (best < 0 || score(move) > score(mon.moves[best])) best = i;
|
||||
if (best < 0 || weighed(move) > weighed(mon.moves[best])) best = i;
|
||||
});
|
||||
return Math.max(0, best);
|
||||
};
|
||||
private matchups?: ReturnType<BattleData["typeMatchups"]>;
|
||||
private screenLabel = "";
|
||||
private screenStill = 0;
|
||||
/** Run scripts and battles until the player is free again. Battles use
|
||||
* the strongest move with PP; a wild battle below half HP is run from,
|
||||
* and a trainer battle (which cannot be run from) instead reaches for
|
||||
@@ -122,6 +131,9 @@ export class Playthrough {
|
||||
const awakening = world.liveState.bag.items.findIndex(
|
||||
(slot) => slot.item === C.AWAKENING,
|
||||
);
|
||||
const fullHeal = world.liveState.bag.items.findIndex(
|
||||
(slot) => slot.item === C.FULL_HEAL,
|
||||
);
|
||||
if (
|
||||
engine.battleMode === C.WILD_BATTLE &&
|
||||
engine.enemy.mon.species === this.wanted &&
|
||||
@@ -143,9 +155,17 @@ export class Playthrough {
|
||||
)
|
||||
world.battleAction({ action: "item", item: C.AWAKENING, slot: awakening });
|
||||
else if (
|
||||
this.careful &&
|
||||
engine.battleMode !== C.WILD_BATTLE &&
|
||||
!prompt.noItems &&
|
||||
mon.hp * 2 < mon.maxHp &&
|
||||
mon.status & ((1 << C.PSN) | (1 << C.PAR) | (1 << C.BRN)) &&
|
||||
fullHeal >= 0
|
||||
)
|
||||
world.battleAction({ action: "item", item: C.FULL_HEAL, slot: fullHeal });
|
||||
else if (
|
||||
engine.battleMode !== C.WILD_BATTLE &&
|
||||
!prompt.noItems &&
|
||||
mon.hp * (this.careful ? 3 : 2) < mon.maxHp * (this.careful ? 2 : 1) &&
|
||||
potion >= 0
|
||||
)
|
||||
world.battleAction({
|
||||
@@ -192,6 +212,15 @@ export class Playthrough {
|
||||
const last = dialogue.index === dialogue.pages.length - 1;
|
||||
if (log.at(-1) !== page) log.push(page);
|
||||
if (last && dialogue.choice?.kind === "screen" && dialogue.choice.realtime) {
|
||||
// A player watching a screen that runs on its own (the Hall of
|
||||
// Fame's rating, THE END) presses A once it has stood still for
|
||||
// four seconds.
|
||||
const label = dialogue.choice.screen.label;
|
||||
if (label !== this.screenLabel) [this.screenLabel, this.screenStill] = [label, 0];
|
||||
else if (++this.screenStill >= 120) {
|
||||
world.bufferScreenButton("a");
|
||||
this.screenStill = 0;
|
||||
}
|
||||
world.tick();
|
||||
continue;
|
||||
}
|
||||
@@ -223,15 +252,25 @@ export class Playthrough {
|
||||
throw new Error(`The world did not settle: ${world.diagnostic}`);
|
||||
};
|
||||
|
||||
/** Where a warp of the current map lands when it leads elsewhere on the
|
||||
* same map (Victory Road's ladders, a building's inner stairs). */
|
||||
private sameMapArrival = (x: number, y: number) => {
|
||||
const world = this.world;
|
||||
const warp = world.map.warps.find((warp) => warp.x === x && warp.y === y);
|
||||
return warp?.targetMap === world.mapId ? world.map.warps[warp.targetWarp - 1] : undefined;
|
||||
};
|
||||
/** Walk to a tile on the current map, replanning after ledges, roaming
|
||||
* NPCs and scenes that move the player. A warp target leaves the map. */
|
||||
* NPCs and scenes that move the player. A warp target leaves the map, or
|
||||
* lands at its destination on the same map. */
|
||||
walk = (x: number, y: number) => {
|
||||
const world = this.world,
|
||||
map = world.mapId,
|
||||
warp = world.map.warps.some((warp) => warp.x === x && warp.y === y);
|
||||
warp = world.map.warps.some((warp) => warp.x === x && warp.y === y),
|
||||
arrival = this.sameMapArrival(x, y);
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
this.play();
|
||||
if (warp && world.mapId !== map) return;
|
||||
if (arrival && world.x === arrival.x && world.y === arrival.y) return;
|
||||
expect(world.mapId, `walking to ${x},${y}`).toBe(map);
|
||||
if (world.x === x && world.y === y) return;
|
||||
// A surfer crosses water until it steps ashore.
|
||||
@@ -511,22 +550,29 @@ export class Playthrough {
|
||||
continue;
|
||||
}
|
||||
// A surfer reaches a cave's stairs over water, then steps on.
|
||||
const landedHere = () => {
|
||||
const arrival = warp.targetMap === from ? world.map.warps[warp.targetWarp - 1] : undefined;
|
||||
return Boolean(arrival && world.x === arrival.x && world.y === arrival.y);
|
||||
};
|
||||
if (water) {
|
||||
try {
|
||||
this.sail(warp.x, warp.y, { whirlpools: true });
|
||||
} catch {
|
||||
if (world.mapId === from) continue;
|
||||
if (world.mapId === from && !landedHere()) continue;
|
||||
}
|
||||
} else
|
||||
try {
|
||||
this.walk(warp.x, warp.y);
|
||||
} catch {
|
||||
// A warp panel on the way can land elsewhere on the same map.
|
||||
if (world.mapId === from) continue;
|
||||
if (world.mapId === from && !landedHere()) continue;
|
||||
}
|
||||
const exit = WARP_EXITS[world.assets.collision(world.map, warp.x, warp.y)];
|
||||
if (world.mapId === from && exit && world.move(exit)) this.play();
|
||||
if (world.mapId === from) continue;
|
||||
// A warp to another place on the same map lands at its partner.
|
||||
const arrival = warp.targetMap === from ? world.map.warps[warp.targetWarp - 1] : undefined;
|
||||
const moved = arrival ? world.x === arrival.x && world.y === arrival.y : world.mapId !== from;
|
||||
if (!moved) continue;
|
||||
if (world.mapId === target) return;
|
||||
const key = `${world.mapId} ${world.x},${world.y}`;
|
||||
if (seen.has(key)) continue;
|
||||
|
||||
+168
-1
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, onTestFailed } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import { tmhmItem } from "../src/game/rom/pokemon";
|
||||
import { Playthrough } from "./playthrough-driver";
|
||||
import { findPath } from "./world-driver";
|
||||
|
||||
@@ -214,6 +215,29 @@ const ROUTE_46 = "5:9",
|
||||
TIN_TOWER_1F = "3:4",
|
||||
EVENT_RANG_CLEAR_BELL_2 = 0x767,
|
||||
EVENT_FOUGHT_SUICUNE = 0x335;
|
||||
const WISE_TRIOS_ROOM_EXIT = "4:2",
|
||||
MAHOGANY_ROUTE_44 = "2:6",
|
||||
ROUTE_27 = "24:2",
|
||||
TOHJO_FALLS = "3:83",
|
||||
ROUTE_26 = "24:1",
|
||||
VICTORY_ROAD_GATE = "23:13",
|
||||
VICTORY_ROAD = "3:91",
|
||||
ROUTE_23 = "16:1",
|
||||
INDIGO_PLATEAU = "16:2",
|
||||
ELITE_FOUR = ["16:3", "16:4", "16:5", "16:6"],
|
||||
LANCES_ROOM = "16:7";
|
||||
const HM07 = 0xf9,
|
||||
WATERFALL = 127,
|
||||
IRON_TAIL = 231,
|
||||
TYPHLOSION = 157,
|
||||
EVENT_GOT_HM07_WATERFALL = 0x688,
|
||||
EVENT_RIVAL_VICTORY_ROAD = 0x6c2,
|
||||
EVENT_BEAT_ELITE_4_WILL = 0x5b8,
|
||||
EVENT_BEAT_ELITE_4_KOGA = 0x5b9,
|
||||
EVENT_BEAT_ELITE_4_BRUNO = 0x5ba,
|
||||
EVENT_BEAT_ELITE_4_KAREN = 0x5bb,
|
||||
EVENT_BEAT_CHAMPION_LANCE = 0x5bc,
|
||||
EVENT_BEAT_ELITE_FOUR = 0x44;
|
||||
|
||||
/** The acceptance run for PLAN section 2: a new game played from the
|
||||
* introduction to the catching tutorial through the world's own inputs,
|
||||
@@ -343,7 +367,8 @@ describe.skipIf(!existsSync(romPath))(
|
||||
// The rival's scene blocks the way east to Route 29.
|
||||
cross("right", ROUTE_29);
|
||||
const rival = p.log.join("\n");
|
||||
expect(rival).toContain("RIVAL ?");
|
||||
// PlaceEnemysName: a rival is wRivalName alone, "???" until named.
|
||||
expect(rival).toContain("???\nwants to battle!");
|
||||
expect(rival).toContain("My name's ???.");
|
||||
expect(p.world.state.scenes[CHERRYGROVE]).toBe(0);
|
||||
reload();
|
||||
@@ -1486,5 +1511,147 @@ describe.skipIf(!existsSync(romPath))(
|
||||
reload();
|
||||
writeTestSave("tin-tower");
|
||||
}, 240_000);
|
||||
|
||||
it("goes back to Ice Path for HM07 and walks home to New Bark Town", () => {
|
||||
traceFailures();
|
||||
// From here on the run fights as a player facing the League would.
|
||||
p.careful = true;
|
||||
// Tin Tower's grounds lead back through the Wise Trio's room and the
|
||||
// entrance building, whose far side comes down by its stairs.
|
||||
stairs(ECRUTEAK);
|
||||
stairs(WISE_TRIOS_ROOM_EXIT);
|
||||
stairs(TIN_TOWER_ENTRANCE);
|
||||
walk(17, 14);
|
||||
p.world.move("down");
|
||||
play();
|
||||
stairs(ECRUTEAK);
|
||||
stairs(ROUTE_42_GATE);
|
||||
stairs(ROUTE_42);
|
||||
cross("right", MAHOGANY, { water: true });
|
||||
heal(MAHOGANY_CENTER, MAHOGANY);
|
||||
cross("right", MAHOGANY_ROUTE_44);
|
||||
stairs(ICE_PATH[0]);
|
||||
// The HM07 ball at (31, 7), reached from its left over the ice.
|
||||
walk(30, 7);
|
||||
if (p.world.save().direction !== "right") p.world.move("right");
|
||||
p.world.interact();
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_GOT_HM07_WATERFALL)).toBe(true);
|
||||
expect(p.world.state.bag.tmsHms[56]).toBe(1);
|
||||
stairs(MAHOGANY_ROUTE_44);
|
||||
cross("left", MAHOGANY);
|
||||
cross("left", ROUTE_42, { water: true });
|
||||
sail(2, 8);
|
||||
stairs(ROUTE_42_GATE);
|
||||
stairs(ECRUTEAK);
|
||||
cross("down", ROUTE_37);
|
||||
cross("down", ROUTE_36);
|
||||
cross("right", VIOLET);
|
||||
stairs(ROUTE_31_GATE);
|
||||
stairs(ROUTE_31);
|
||||
cross("down", ROUTE_30);
|
||||
cross("down", CHERRYGROVE);
|
||||
cross("right", ROUTE_29);
|
||||
cross("right", NEW_BARK);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("surfs Route 27, climbs Tohjo Falls and takes Victory Road to the Plateau", () => {
|
||||
traceFailures();
|
||||
// Dratini learns Waterfall; Typhlosion takes Iron Tail for Leer.
|
||||
p.teach(HM07, 5, 0);
|
||||
p.teach(tmhmItem(23), 0, 1);
|
||||
expect(p.world.state.party[5].moves[0]).toBe(WATERFALL);
|
||||
expect(p.world.state.party[0].moves[1]).toBe(IRON_TAIL);
|
||||
cross("right", ROUTE_27, { water: true });
|
||||
travel(TOHJO_FALLS, [], { water: true });
|
||||
// Up the western waterfall, along the top pool and down the eastern
|
||||
// one to the far shore and Route 27's other half.
|
||||
sail(9, 12);
|
||||
if (p.world.save().direction !== "up") p.world.move("up");
|
||||
p.world.interact();
|
||||
play([1]);
|
||||
expect({ x: p.world.x, y: p.world.y }).toEqual({ x: 9, y: 7 });
|
||||
sail(22, 14);
|
||||
walk(25, 15);
|
||||
if (p.world.mapId === TOHJO_FALLS) {
|
||||
p.world.move("down");
|
||||
play();
|
||||
}
|
||||
expect(p.world.mapId).toBe(ROUTE_27);
|
||||
cross("right", ROUTE_26, { water: true });
|
||||
travel(VICTORY_ROAD_GATE, [], { water: true });
|
||||
// VictoryRoadGateBadgeCheckScript on the way north.
|
||||
stairs(VICTORY_ROAD);
|
||||
expect(p.log.join(" ")).toContain("BADGES of JOHTO!");
|
||||
// Victory Road's floors are joined by its own ladders; the rival
|
||||
// waits by the exit.
|
||||
travel(ROUTE_23, [VICTORY_ROAD]);
|
||||
// The rival leaves (EVENT_RIVAL_VICTORY_ROAD hides him) after the battle.
|
||||
expect(p.world.state.events.has(EVENT_RIVAL_VICTORY_ROAD)).toBe(true);
|
||||
expect(p.log.join(" ")).toContain("SILVER\nwants to battle!");
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("stocks up at Indigo Plateau and beats Will", () => {
|
||||
traceFailures();
|
||||
stairs(INDIGO_PLATEAU);
|
||||
talkTo(2); // the nurse, across the counter
|
||||
play();
|
||||
expect(p.world.state.party.every((mon) => mon.hp === mon.stats[0])).toBe(true);
|
||||
writeTestSave("indigo-plateau");
|
||||
buy(3, C.FULL_HEAL, 15);
|
||||
buy(3, C.MAX_POTION, Math.floor((p.world.state.money - 2000) / 2500));
|
||||
stairs(ELITE_FOUR[0]);
|
||||
play(); // the door locks behind the player
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_BEAT_ELITE_4_WILL)).toBe(true);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("beats Koga, Bruno and Karen", () => {
|
||||
traceFailures();
|
||||
const events = [EVENT_BEAT_ELITE_4_KOGA, EVENT_BEAT_ELITE_4_BRUNO, EVENT_BEAT_ELITE_4_KAREN];
|
||||
ELITE_FOUR.slice(1).forEach((room, index) => {
|
||||
stairs(room);
|
||||
play();
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play();
|
||||
expect(p.world.mapId).toBe(room);
|
||||
expect(p.world.state.events.has(events[index])).toBe(true);
|
||||
});
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("beats Lance and enters the Hall of Fame, then wakes in New Bark Town", () => {
|
||||
traceFailures();
|
||||
stairs(LANCES_ROOM);
|
||||
play();
|
||||
// Script_ApproachLanceFromRight: the step towards Lance starts it;
|
||||
// after the battle Mary and Oak rush in and Lance leads the way to
|
||||
// the Hall of Fame, where the ceremony and the credits play.
|
||||
walk(5, 6);
|
||||
p.world.move("up");
|
||||
play();
|
||||
const log = p.log.join(" ");
|
||||
expect(log).toContain("CHAMPION LANCE\nwas defeated!");
|
||||
expect(log).toContain("MARY: Let's inter-");
|
||||
expect(log).toContain("and your partners\nas CHAMPIONS!");
|
||||
expect(p.world.state.events.has(EVENT_BEAT_CHAMPION_LANCE)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_BEAT_ELITE_FOUR)).toBe(true);
|
||||
expect(p.world.state.hallOfFame).toBe(true);
|
||||
expect(p.world.state.hallOfFameCount).toBe(1);
|
||||
expect(p.world.state.hallOfFameTeams[0].mons.map((mon) => mon.species)).toEqual(
|
||||
p.world.state.party.map((mon) => mon.species),
|
||||
);
|
||||
expect(p.world.state.hallOfFameTeams[0].mons[0].species).toBe(TYPHLOSION);
|
||||
// Continue after the credits: SPAWN_NEW_BARK, outside the lab.
|
||||
expect(p.world.mapId).toBe(NEW_BARK);
|
||||
expect({ x: p.world.x, y: p.world.y }).toEqual({ x: 13, y: 6 });
|
||||
expect(p.world.busy).toBe(false);
|
||||
reload();
|
||||
writeTestSave("hall-of-fame-new-bark");
|
||||
}, 300_000);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,184 @@
|
||||
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([]);
|
||||
});
|
||||
@@ -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