Breed at the Day-Care, hatch Eggs, draw MAIL stationery and browse the Hall of Fame (Closes #29)

- Day-Care and breeding: deposits, withdrawal pricing and growth,
  compatibility, egg species, DVs and moves, DayCareStep, egg
  collection and the Odd Egg, compared with the cartridge's routines.
- CountStep: walking happiness, DoEggStep, DayCareStep and DoBikeStep
  in the original's order; HatchEggs with the ROM's hatch animation,
  compared frame by frame with the original.
- MAIL: all ten stationery types and the Portrait MAIL picture,
  compared with the cartridge's tilemaps, graphics and palettes.
- Printing: Bill's PC boxes, Pokedex PRNT, MAIL and the Photo Studio
  take the original's absent-printer path.
- Hall of Fame records (saved apart from the League flag, ready for the
  League to call) and the Hall of Fame PC, compared with the cartridge's
  _HallOfFamePC on 255 pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-17 08:52:30 +02:00
1 parent 7d03f5c5ca
commit e49bc6de9a
49 files changed
+6028 -88

No files matched your search

File diff suppressed because it is too large. Load diff
+236
View File
@@ -0,0 +1,236 @@
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 { doEggStep, stepHappiness } from "../src/game/party/eggs";
import {
generatePartyMon,
caughtData,
type PartyMon,
} from "../src/game/party/pokemon";
import { HatchRun } from "../src/game/scripts/hatching";
import { emptyScriptState } from "../src/game/scripts/interpreter";
import { defaultTextContext } from "../src/game/scripts/text";
import { EVENTS } from "../src/game/rom/profile";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe("egg step byte rules", () => {
const egg = (happiness: number) => ({ egg: true, happiness }) as PartyMon;
it("stops decrementing at the first ready Egg and wraps a zero byte", () => {
const party = [egg(0), egg(1), egg(5)];
expect(doEggStep(party)).toBe(true);
expect(party.map((m) => m.happiness)).toEqual([255, 0, 5]);
expect(doEggStep(party)).toBe(false);
expect(party.map((m) => m.happiness)).toEqual([254, 255, 4]);
});
it("raises happiness only on alternate wraps, excluding Eggs and capping at 255", () => {
const counters = { happinessStepCount: 0 };
const party = [
egg(20),
{ happiness: 254 } as PartyMon,
{ happiness: 255 } as PartyMon,
];
stepHappiness(counters, party);
expect(party.map((m) => m.happiness)).toEqual([20, 254, 255]);
stepHappiness(counters, party);
expect(party.map((m) => m.happiness)).toEqual([20, 255, 255]);
expect(counters.happinessStepCount).toBe(0);
});
});
describe.skipIf(!existsSync(romPath))("hatching with the local ROM", () => {
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const egg = (a: CrystalAssets, species = 175, happiness = 1) =>
Object.assign(
generatePartyMon(a.pokemon, {
species,
level: 5,
item: 0,
otId: 111,
ot: "ELM",
dvs: 0x8888,
caughtData: 0,
}),
{ egg: true as const, nickname: "EGG", happiness, hp: 0 },
);
const world = () => {
const w = new GameWorld(assets(), () => new Date("2026-09-22T12:00:00"));
w.restore({ ...w.save(), map: "24:3", x: 12, y: 3, direction: "left" });
settle(w);
w.state.noWildEncounters = true;
return w;
};
const walk = (w: GameWorld, direction: "left" | "right") => {
expect(w.move(direction)).toBe(true);
for (let i = 0; i < 100 && !w.dialogue && w.busy; i++) w.tick();
};
it("hatches every zero-cycle Egg in party order with caught data, ownership and naming", () => {
const a = assets(),
state = emptyScriptState();
state.party = [egg(a, 175, 0), egg(a, 172, 5), egg(a, 173, 0)];
const run = new HatchRun(state, {
reader: a.reader,
data: a.pokemon,
timeOfDay: () => 2,
landmark: () => 12,
player: () => ({ id: 456, gender: 1 }),
text: () => defaultTextContext("KRIS"),
});
const questions: string[] = [];
let effect = run.resume();
while (effect) {
let input: number | string | undefined;
if (effect.kind === "yesno") {
questions.push(effect.pages.join(" "));
input = 1;
}
if (effect.kind === "name")
input = questions.length === 1 ? "SHELLY" : " ";
effect = run.resume(input);
}
expect(state.party.every((mon) => mon.egg)).toBe(true);
expect(run.state.party.map((mon) => mon.nickname)).toEqual([
"SHELLY",
"EGG",
"CLEFFA",
]);
for (const mon of [run.state.party[0], run.state.party[2]]) {
expect(mon.egg).toBeUndefined();
expect(mon).toMatchObject({
happiness: 120,
otId: 456,
ot: "KRIS",
status: 0,
caughtData: caughtData(2, 1, 1, 12),
});
expect(mon.hp).toBe(mon.stats[0]);
expect(run.state.pokedex.caught.has(mon.species)).toBe(true);
}
expect(run.state.events.has(EVENTS.togepiHatched)).toBe(true);
});
it("walks the full Togepi cycle and replays a save made during the hatch", () => {
const w = world();
w.state.party = [egg(w.assets, 175, 2)];
for (let i = 0; i < 383; i++) {
walk(w, i % 2 ? "right" : "left");
settle(w);
}
expect(w.state.party[0].happiness).toBe(1);
expect(w.state.stepCount).toBe(127);
const before = w.save();
walk(w, "right");
expect(w.dialogue).toBeDefined();
const saved = w.save();
expect(saved.party[0]).toEqual(before.party[0]);
expect(saved.stepCount).toBe(127);
expect([saved.x, saved.y, saved.tilesWalked]).toEqual([
before.x,
before.y,
before.tilesWalked,
]);
// Reach naming while the whole hatch stays uncommitted.
settle(w, [1]);
expect(w.dialogue?.choice?.kind).toBe("name");
expect(w.save().party[0].egg).toBe(true);
const replay = world();
replay.restore(w.save());
walk(replay, "right");
settle(replay, [0]);
expect(replay.state.party[0]).toMatchObject({
happiness: 120,
nickname: "TOGEPI",
});
expect(replay.state.party[0].egg).toBeUndefined();
expect(replay.state.events.has(EVENTS.togepiHatched)).toBe(true);
expect(replay.state.stepCount).toBe(128);
expect(replay.save().tilesWalked).toBe(before.tilesWalked + 1);
});
it("counts 512 walking steps for happiness", () => {
const w = world();
const mon = egg(w.assets);
delete mon.egg;
mon.happiness = 70;
w.state.party = [mon];
for (let i = 0; i < 512; i++) {
walk(w, i % 2 ? "right" : "left");
settle(w);
}
expect(mon.happiness).toBe(71);
expect(w.state.stepCount).toBe(0);
expect(w.state.poisonStepCount).toBe(0);
});
it("rolls back the triggering step and Egg cycles if presentation fails", () => {
const w = world();
w.state.party = [egg(w.assets)];
w.state.stepCount = 127;
w.state.repel = 10;
const before = w.save();
w.assets.pokemon.eggPic = () => {
throw Error("Injected hatch picture failure");
};
walk(w, "left");
w.advanceDialogue();
expect(w.notice).not.toBe("");
const after = w.save();
expect([
after.x,
after.y,
after.tilesWalked,
after.stepCount,
after.poisonStepCount,
after.repel,
]).toEqual([
before.x,
before.y,
before.tilesWalked,
before.stepCount,
before.poisonStepCount,
before.repel,
]);
expect(after.party).toEqual(before.party);
expect(after.events).toEqual(before.events);
});
it("does not count a step whose Repel expires", () => {
const w = world();
w.state.repel = 1;
w.state.stepCount = 127;
w.state.party = [egg(w.assets)];
walk(w, "left");
settle(w);
expect(w.state.stepCount).toBe(127);
expect(w.state.party[0].happiness).toBe(1);
expect(w.state.repel).toBe(0);
});
it("migrates absent counters, rejects malformed ones and preserves saves atomically", () => {
const w = world(),
save = w.save();
const old = { ...save } as Partial<typeof save>;
for (const key of [
"stepCount",
"poisonStepCount",
"happinessStepCount",
"bikeStep",
] as const)
delete old[key];
w.restore(old);
expect(w.save()).toMatchObject({
stepCount: 0,
poisonStepCount: 0,
happinessStepCount: 0,
bikeStep: 0,
});
for (const change of [
{ stepCount: 256 },
{ poisonStepCount: -1 },
{ happinessStepCount: 2 },
{ bikeStep: 65536 },
]) {
expect(() => w.restore({ ...save, ...change })).toThrow();
expect(w.save().stepCount).toBe(0);
}
});
});
+9
View File
@@ -570,6 +570,15 @@ test("MAIL is written when given, and read from the party menu", async ({
"HELLO, CHRIS! \nSEE YOU!",
);
await expect(page.locator("#mail-author")).toHaveText("CHRIS");
await expect(page.locator("#mail-canvas")).toBeVisible();
await page.keyboard.press("Enter");
await expect(letter).toBeHidden();
await expect(page.locator("#tile-screen-canvas")).toHaveAttribute("aria-label", /Printer Error 2/);
await page.keyboard.press("KeyX");
await expect(page.locator("#tile-screen")).toBeVisible();
await page.keyboard.press("KeyZ");
await expect(letter).toBeVisible();
await expect(page.locator("#mail-message")).toHaveText("HELLO, CHRIS! \nSEE YOU!");
await page.locator("#mail-close").click();
await expect(letter).toBeHidden();
await expect(page.locator("#game-notice")).toHaveText("");
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import {
addHallOfFameEntry,
getHallOfFameParty,
validHallOfFame,
type HallOfFameRecords,
} from "../src/game/party/hall-of-fame";
import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon";
import { CrystalAssets } from "../src/game/rom/assets";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { hallOfFamePC } from "../src/game/scripts/hall-of-fame";
import { GameWorld } from "../src/game/world/world";
const minimal = {
species: 25,
otId: 12345,
dvs: 0xaaaa,
level: 42,
nickname: "SPARK",
} as PartyMon;
describe("Hall of Fame records", () => {
it("copies just the historical fields and skips Eggs, retaining order", () => {
const team = getHallOfFameParty(
[
minimal,
{ ...minimal, species: 175, egg: true },
{ ...minimal, species: 133 },
],
1,
);
expect(team).toEqual({
win: 1,
mons: [minimal, { ...minimal, species: 133 }],
});
team.mons[0].nickname = "OLD";
expect(minimal.nickname).toBe("SPARK");
expect(getHallOfFameParty([{ ...minimal, egg: true }], 2)).toEqual({
win: 2,
mons: [],
});
});
it("shifts out the oldest team at 30 and caps wins at 200 independently of the League flag", () => {
const state = {
hallOfFameCount: 0,
hallOfFameTeams: [],
hallOfFame: false,
} as HallOfFameRecords & { hallOfFame: boolean };
for (let i = 0; i < 35; i++)
addHallOfFameEntry(state, [{ ...minimal, otId: i }]);
expect(state.hallOfFameTeams).toHaveLength(30);
expect(state.hallOfFameTeams.map((team) => team.win)).toEqual(
Array.from({ length: 30 }, (_, i) => 35 - i),
);
expect(state.hallOfFameTeams[29].mons[0].otId).toBe(5);
for (let i = 35; i < 205; i++) addHallOfFameEntry(state, [minimal]);
expect(state.hallOfFameCount).toBe(200);
expect(state.hallOfFameTeams.slice(0, 6).map((team) => team.win)).toEqual(
Array(6).fill(200),
);
expect(state.hallOfFame).toBe(false);
expect(validHallOfFame(state.hallOfFameCount, state.hallOfFameTeams)).toBe(
true,
);
});
it("rejects malformed records and count/history contradictions", () => {
expect(validHallOfFame(0, [])).toBe(true);
for (const [count, teams] of [
[null, []],
[201, []],
[1, []],
[0, [getHallOfFameParty([minimal], 1)]],
[1, null],
[1, [{ win: 1, mons: null }]],
[1, [{ win: 1, mons: [{ ...minimal, species: 253 }] }]],
[1, [{ win: 1, mons: Array(7).fill(minimal) }]],
] as const)
expect(validHallOfFame(count, teams)).toBe(false);
});
});
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))("Hall of Fame with the local ROM", () => {
const assets = existsSync(romPath)
? new CrystalAssets(new Uint8Array(readFileSync(romPath)))
: undefined;
const fixture = () => {
const world = new GameWorld(assets!);
world.state.party = [
generatePartyMon(assets!.pokemon, {
species: 25,
level: 42,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0xaaaa,
caughtData: 0,
}),
];
world.restore({
...world.save(),
map: "26:5",
x: 9,
y: 2,
direction: "up",
objects: [],
});
return world;
};
const untilChoice = (world: GameWorld) => {
for (let i = 0; i < 300; i++) {
if (world.dialogue?.choice) return world.dialogue.choice;
if (world.dialogue) world.advanceDialogue();
else if (world.busy) world.tick();
else return;
}
throw Error("PC did not settle");
};
it("uses the count and Pokédex for the PC menu, then routes screen buttons back to the PC", () => {
const world = fixture();
world.state.flags.add(ENGINE_FLAGS.pokedex);
world.state.hallOfFame = true;
world.interact();
let menu = untilChoice(world);
expect(menu?.kind).toBe("menu");
if (menu?.kind !== "menu") return;
expect(menu.items).not.toContain("HALL OF FAME");
world.choose(-1);
untilChoice(world);
world.state.hallOfFame = false;
addHallOfFameEntry(world.state, world.state.party);
world.interact();
menu = untilChoice(world);
if (menu?.kind !== "menu") throw Error("No PC menu");
expect(menu.items).toContain("HALL OF FAME");
world.choose(menu.items.indexOf("HALL OF FAME"));
const page = untilChoice(world);
expect(page?.kind).toBe("screen");
if (page?.kind === "screen") expect(page.screen.label).toContain("PIKACHU");
world.choose("b");
expect(untilChoice(world)?.kind).toBe("menu");
world.choose(-1);
untilChoice(world);
world.state.flags.delete(ENGINE_FLAGS.pokedex);
world.interact();
menu = untilChoice(world);
if (menu?.kind !== "menu") throw Error("No PC menu");
expect(menu.items).not.toContain("HALL OF FAME");
});
it("browses newest first, ignores directions, advances with A and skips teams with Start", () => {
const teams = [
getHallOfFameParty([minimal, { ...minimal, nickname: "SECOND" }], 3),
{ win: 2, mons: [] },
getHallOfFameParty([{ ...minimal, nickname: "OLDER" }], 1),
];
const run = hallOfFamePC(assets!.pokemon, teams, 0);
const label = (result: ReturnType<typeof run.next>) =>
!result.done && result.value.kind === "screen"
? result.value.screen.label
: "";
expect(label(run.next())).toContain("SPARK");
expect(label(run.next("down"))).toContain("SPARK");
expect(label(run.next("a"))).toContain("SECOND");
expect(label(run.next("start"))).toContain("OLDER");
expect(run.next("a").done).toBe(true);
const cancel = hallOfFamePC(assets!.pokemon, teams, 0);
cancel.next();
expect(cancel.next("b").done).toBe(true);
});
it("round-trips records, migrates by absence, and rejects explicit damage", () => {
const world = fixture();
addHallOfFameEntry(world.state, world.state.party);
const saved = JSON.parse(JSON.stringify(world.save()));
const loaded = fixture();
loaded.restore(saved);
expect(loaded.state.hallOfFameTeams).toEqual(world.state.hallOfFameTeams);
saved.hallOfFameTeams[0].mons[0].nickname = "CHANGED";
expect(loaded.state.hallOfFameTeams[0].mons[0].nickname).toBe("PIKACHU");
const legacy = JSON.parse(JSON.stringify(world.save()));
delete legacy.hallOfFameCount;
delete legacy.hallOfFameTeams;
loaded.restore(legacy);
expect(loaded.state.hallOfFameTeams).toEqual([]);
expect(loaded.state.hallOfFameCount).toBe(0);
for (const mutate of [
(s: any) => (s.hallOfFameCount = null),
(s: any) => (s.hallOfFameTeams = null),
(s: any) => (s.hallOfFameTeams[0].mons[0].level = 101),
]) {
const bad = JSON.parse(JSON.stringify(world.save()));
mutate(bad);
expect(() => loaded.restore(bad)).toThrow(/Invalid native save/);
}
});
});
+105 -1
View File
@@ -3,9 +3,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } 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 { addHallOfFameEntry } from "../src/game/party/hall-of-fame";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { CRYSTAL_ROM } from "../src/rom/validate";
import { encodeSave } from "../src/storage/saves";
import { foundationFixture } from "./foundation-fixture";
import { settle } from "./world-driver";
// Backups for the manual checks on the project board's "A tester" column,
// built from the local ROM and imported through Savegames → Import backup
@@ -28,7 +31,9 @@ describe.skipIf(!existsSync(romPath) || !process.env.MAKE_TEST_SAVES)(
function write(name: string, world: GameWorld) {
const state = new TextEncoder().encode(JSON.stringify(world.save()));
// The backup must load as the app would load it.
new GameWorld(assets()).restore(JSON.parse(new TextDecoder().decode(state)));
new GameWorld(assets()).restore(
JSON.parse(new TextDecoder().decode(state)),
);
mkdirSync(OUT, { recursive: true });
writeFileSync(
`${OUT}/${name}.crystal-save.json`,
@@ -97,5 +102,104 @@ describe.skipIf(!existsSync(romPath) || !process.env.MAKE_TEST_SAVES)(
expect(world.mapId).toBe("24:3");
write("headbutt", world);
});
/** A Pokémon of the player's, from (species, level, DVs). */
const caught = (
a: CrystalAssets,
world: GameWorld,
species: number,
level: number,
dvs = 0x9abc,
) =>
generatePartyMon(a.pokemon, {
species,
level,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs,
caughtData: 0,
});
it("writes day-care: inside the Day-Care with a pair that breeds", () => {
const a = assets();
const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00"));
// A female Cyndaquil and a male Typhlosion (Attack and Speed DVs 0
// and 15), and a Totodile to keep. The Odd Egg is still to come.
world.state.party = [
caught(a, world, 155, 20, 0x0201), // CYNDAQUIL
caught(a, world, 157, 40, 0xf7f2), // TYPHLOSION
caught(a, world, 158, 15), // TOTODILE
];
world.state.money = 50000;
world.warpTo("11:24", 2, 4); // facing the man
settle(world);
world.restore({ ...world.save(), direction: "up" });
expect(world.mapId).toBe("11:24");
write("day-care", world);
});
it("writes egg-hatching: Route 29 with two Eggs two steps from hatching", () => {
const a = assets();
const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00"));
const egg = (species: number, dvs: number, happiness: number) => ({
...caught(a, world, species, 5, dvs),
egg: true as const,
nickname: "EGG",
happiness,
hp: 0,
});
// The Togepi Egg reaches zero on step 128 and stops DoEggStep; the
// shiny Pichu's is already zero, so both hatch.
world.state.party = [
caught(a, world, 155, 20),
egg(175, 0x8888, 1), // TOGEPI
egg(172, 0x2aaa, 0), // PICHU
];
world.restore({
...world.save(),
map: "24:3",
x: 12,
y: 3,
direction: "left",
stepCount: 0x7e,
});
settle(world);
write("egg-hatching", 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"));
world.state.flags.add(ENGINE_FLAGS.pokedex);
const record = (party: [number, number, number][]) =>
addHallOfFameEntry(
world.state,
party.map(([species, level, dvs]) =>
caught(a, world, species, level, dvs),
),
);
record([[201, 50, 0xffff]]); // UNOWN
record([
[157, 55, 0x9abc], // TYPHLOSION
[29, 30, 0x0000], // NIDORAN♀
[32, 31, 0xffff], // NIDORAN♂
[25, 100, 0x2aaa], // PIKACHU, shiny
[175, 12, 0x9abc], // TOGEPI
[249, 70, 0x9abc], // LUGIA
]);
record([[158, 42, 0x9abc]]); // TOTODILE
world.state.party = [caught(a, world, 155, 20)];
world.restore({
...world.save(),
map: "26:5",
x: 9,
y: 2,
direction: "up",
objects: [],
});
settle(world);
write("hall-of-fame", world);
});
},
);
+26
View File
@@ -256,6 +256,32 @@ describe.skipIf(!existsSync(romPath))("MAIL", () => {
expect(world.state.bag.items).toEqual([{ item: FLOWER_MAIL, quantity: 1 }]);
});
it("prints with Start, waits for B on the error and restores the letter", () => {
const world = fixture();
const mon = world.state.party[0];
mon.item = FLOWER_MAIL;
mon.mail = letter(world, "“HELLO…”");
mailMenu(world);
world.choose(0);
const before = world.dialogue?.choice;
expect(before?.kind).toBe("screen");
world.choose("down");
expect(world.mailView).toEqual(mon.mail);
world.choose("start");
const printer = world.dialogue?.choice;
expect(printer?.kind === "screen" && printer.screen.label).toContain("Printer Error 2");
expect(world.mailView).toBeUndefined();
world.choose("a");
expect(world.dialogue?.choice).toEqual(printer);
world.choose("b");
expect(world.mailView).toEqual(mon.mail);
expect(world.dialogue?.choice).toEqual(before);
world.choose("a");
until(world);
close(world);
expect(world.state.party[0].mail).toEqual(mon.mail);
});
it("keeps a Pokémon holding MAIL out of the boxes", () => {
const world = fixture();
const mon = world.state.party[1];
+10
View File
@@ -317,6 +317,16 @@ describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => {
expect(texts.some((text) => text.startsWith("Hit "))).toBe(false);
});
it("lets an Egg with stored HP join Beat Up, but skips a zero-HP Egg", () => {
const party = [
mon(CYNDAQUIL, 30, [BEAT_UP], { nickname: "ALPHA" }),
mon(TOTODILE, 5, [TACKLE], { nickname: "EGG", egg: true }),
mon(PIDGEY, 5, [TACKLE], { nickname: "EGG", egg: true, hp: 0 }),
];
const { texts } = firstTurn(duel(party, [mon(SNORLAX, 60, [SPLASH])]));
expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["ALPHA's attack!", "EGG's attack!"]);
});
it("lets a lone Pokémon beat up once, then ends the move", () => {
const { texts } = firstTurn(duel([mon(CYNDAQUIL, 30, [BEAT_UP], { nickname: "ALPHA" })], [mon(SNORLAX, 60, [SPLASH])]));
expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["ALPHA's attack!"]);
+4 -4
View File
@@ -67,7 +67,7 @@ describe.skipIf(!existsSync(romPath))("Olivine and Cianwood side content with th
x: number,
y: number,
face: "up" | "down" | "left" | "right",
answers: number[] = [],
answers: (number | string)[] = [],
) => {
game.warpTo(map, x, y);
settle(game);
@@ -260,18 +260,18 @@ describe.skipIf(!existsSync(romPath))("Olivine and Cianwood side content with th
describe("the Photo Studio (PhotoStudio)", () => {
/** The photographer stands at (2, 3). */
const photo = (game: GameWorld, answers: number[]) =>
const photo = (game: GameWorld, answers: (number | string)[]) =>
talk(game, PHOTO_STUDIO, 2, 4, "up", answers);
it("finds no printer and takes no photo", () => {
const { game } = world();
game.state.party = [mon(game, 155, 20), mon(game, 175, 5, { egg: true })];
const shown = photo(game, [1, 0]);
const shown = photo(game, [1, 0, "b"]);
expect(shown).toContain("Which POKéMON\nshould I photo-\ngraph?");
expect(shown).toContain("Hold\nstill for a bit.");
// PrintPartymon's handshake reads $ff: PRINTER_ERROR_2.
expect(shown).toContain(
" Printer Error 2\n\nCheck the Game Boy\nPrinter Manual.\n\nPress B to Cancel",
" Printer Error 2\n\nCheck the Game Boy\nPrinter Manual.\nPress B to Cancel",
);
expect(shown).toContain("Oh, no picture?\nCome again, OK?");
expect(shown).not.toContain("Presto");
+8
View File
@@ -456,6 +456,14 @@ describe.skipIf(!existsSync(romPath))("the Pokémon Center PC", () => {
until(world);
pick(world, "FAVS");
expect(pick(world, "PRINT")).toContain("There's no POKéMON.");
pick(world, "BOX2");
pick(world, "PRINT");
const printer = world.dialogue?.choice;
expect(printer?.kind === "screen" && printer.screen.label).toContain("Printer Error 2");
world.choose("a");
expect(world.dialogue?.choice).toEqual(printer);
world.choose("b");
until(world);
close(world);
expect(world.state.storage.current).toBe(1);
expect(world.state.storage.names[2]).toBe("FAVS");
+4
View File
@@ -191,6 +191,10 @@ export class Playthrough {
const page = dialogue.pages[dialogue.index];
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) {
world.tick();
continue;
}
if (last && dialogue.choice) {
const answer = answers.shift();
if (dialogue.choice.kind === "name")
+7 -1
View File
@@ -289,10 +289,16 @@ describe.skipIf(!existsSync(romPath))("the Pokédex screen", () => {
expect(view(world).entry!.text).toBe(world.assets.pokedex.entry(CHIKORITA).pages[1]);
press(world, "a");
expect(view(world).entry).toMatchObject({ page: 1 });
// The arrow stops at PRNT and at PAGE; AREA, CRY and PRNT do nothing.
// The arrow stops at PRNT and PAGE; PRNT waits for B on printer error.
for (let i = 0; i < 5; i++) press(world, "right");
expect(view(world).entry).toMatchObject({ action: 3 });
press(world, "a");
expect(world.dialogue?.choice?.kind).toBe("screen");
const printChoice = world.dialogue!.choice!;
if (printChoice.kind === "screen") expect(printChoice.screen.label).toContain("Printer Error 2");
world.choose("a");
expect(world.dialogue?.choice?.kind).toBe("screen");
world.choose("b");
expect(view(world).entry).toMatchObject({ action: 3, page: 1 });
press(world, "left");
expect(view(world).entry).toMatchObject({ action: 2 });
+816
View File
@@ -0,0 +1,816 @@
import { test, expect, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import {
breedLevelGrowth,
breedingCompatibility,
dayCareStep,
emptyDayCare,
initBreeding,
oddEgg,
retrievedMon,
} from "../src/game/party/day-care";
import { ENGINE_FLAGS, JOHTO } from "../src/game/rom/profile";
import type { BoxMon } from "../src/game/party/storage";
import {
romPath,
symbolsPath,
loadSymbols,
openReference,
} from "./reference-fixture";
/** The original Day-Care and breeding routines, executed in the reference
* emulator on random inputs, against the native ones. Only Random is
* replaced (a stub that replays fixed bytes, like reference-smart-ai), so
* every other instruction, table and bank of the cartridge runs. */
const DITTO = 132;
const BOX_STRUCT = 32,
PARTY_STRUCT = 48,
NAME = 11;
/** What one run of a routine in the emulator is asked and returns. */
interface Job {
routine: { bank: number; address: number };
random: { address: number; bytesPerCall: 1 | 2 };
stream: number[];
hROMBank: number;
hRandomAdd: number;
hRandomSub: number;
/** Registers b, d and e after the call are saved at $c0f1 to $c0f3. */
registers?: boolean;
/** Memory to set before each case (once, shared). */
globals: [number, number][];
cases: { writes: [number, number][]; reads: [number, number][] }[];
}
interface Run {
returned: boolean;
draws: number;
registers: number[];
reads: number[][];
}
async function runReference(page: Page, job: Job): Promise<Run[]> {
return page.evaluate(async (job) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const file = (
document.querySelector("#reference-fixture") as HTMLInputElement
).files![0];
const rom = new Uint8Array(await file.arrayBuffer());
// Random becomes JP $c080, to a stub that reads the stream at $c100
// with the counter at $c0e0: hRandomSub (the returned byte) and
// hRandomAdd come from one byte, or from two when asked.
rom.set([0xc3, 0x80, 0xc0], job.random.address);
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const write = (address: number, value: number) =>
reference.writeMemory(address, value & 0xff);
const stub = [
0xe5, // push hl
0xd5, // push de
0xfa,
0xe0,
0xc0, // ld a, [$c0e0]
0x5f, // ld e, a
0x16,
0x00, // ld d, 0
0x21,
0x00,
0xc1, // ld hl, $c100
0x19, // add hl, de
0xc6,
job.random.bytesPerCall, // add a, n
0xea,
0xe0,
0xc0, // ld [$c0e0], a
0x2a, // ld a, [hli]
0xe0,
job.hRandomSub & 0xff, // ldh [hRandomSub], a
...(job.random.bytesPerCall === 2
? [0x7e, 0xe0, job.hRandomAdd & 0xff, 0xf0, job.hRandomSub & 0xff]
: [0xe0, job.hRandomAdd & 0xff]),
0xd1, // pop de
0xe1, // pop hl
0xc9, // ret
];
stub.forEach((value, index) => write(0xc080 + index, value));
const { bank, address } = job.routine;
// DI; SP = $c7fe; select the bank; CALL routine; [save b, d, e];
// sentinel; loop.
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0x3e,
bank,
0xe0,
job.hROMBank & 0xff,
0xea,
0x00,
0x20,
0xcd,
address & 0xff,
address >> 8,
...(job.registers
? [
0x78, 0xea, 0xf1, 0xc0, 0x7a, 0xea, 0xf2, 0xc0, 0x7b, 0xea, 0xf3,
0xc0,
]
: []),
0x3e,
0x42,
0xea,
0xf0,
0xc0,
0x18,
0xfe,
];
write(0xff70, 1);
for (const [address, value] of job.globals) write(address, value);
const runs: Run[] = [];
for (const input of job.cases) {
write(0xc0e0, 0);
write(0xc0f0, 0);
job.stream.forEach((value, index) => write(0xc100 + index, value));
for (const [address, value] of input.writes) write(address, value);
program.forEach((value, index) => write(0xc000 + index, value));
reference.setProgramCounter(0xc000);
// CalcLevel through level 100 needs more than 20 frames.
for (
let frame = 0;
frame < 120 && reference.readMemory(0xc0f0) !== 0x42;
frame++
)
reference.advanceFrames(1);
runs.push({
returned: reference.readMemory(0xc0f0) === 0x42,
draws: reference.readMemory(0xc0e0),
registers: [0xc0f1, 0xc0f2, 0xc0f3].map((address) =>
reference.readMemory(address),
),
reads: input.reads.map(([address, length]) =>
Array.from({ length }, (_, i) => reference.readMemory(address + i)),
),
});
}
reference.dispose();
return runs;
}, job);
}
test.describe("Day-Care routines against the cartridge", () => {
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Needs the local ROM and the private PRET rebuild.",
);
test.setTimeout(240_000);
const symbols = existsSync(symbolsPath) ? loadSymbols() : undefined;
const assets = existsSync(romPath)
? new CrystalAssets(new Uint8Array(readFileSync(romPath)))
: undefined;
const data = () => assets!.pokemon;
const at = (name: string) => symbols!.at(name);
const routine = (name: string) => symbols!.routine(name);
const job = (
name: string,
cases: Job["cases"],
stream: number[],
extra: Partial<Job> = {},
): Job => ({
routine: routine(name),
random: { address: routine("Random").address, bytesPerCall: 1 },
stream,
hROMBank: at("hROMBank"),
hRandomAdd: at("hRandomAdd"),
hRandomSub: at("hRandomSub"),
globals: [],
cases,
...extra,
});
// A small deterministic generator for the cases.
let seed = 0x5eed;
const next = (limit = 256) => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return (seed >>> 8) % limit;
};
const pick = <T>(items: readonly T[]) => items[next(items.length)];
const word = (value: number): [number, number] => [
(value >> 8) & 0xff,
value & 0xff,
];
const set = (address: number, bytes: readonly number[]): [number, number][] =>
bytes.map((value, index) => [address + index, value]);
/** A box_struct as it lies in memory. */
const boxBytes = (mon: BoxMon) => [
mon.species,
mon.item,
...mon.moves,
...word(mon.otId),
(mon.exp >> 16) & 0xff,
...word(mon.exp),
...mon.statExp.flatMap(word),
...word(mon.dvs),
...mon.pp,
mon.happiness,
mon.pokerus,
...word(mon.caughtData),
mon.level,
];
/** The charmap's uppercase letters, ending at the terminator. */
const name = (text: string, length = NAME) =>
[...text.split("").map((letter) => 0x80 + letter.charCodeAt(0) - 65), 0x50]
.concat(Array(length).fill(0x50))
.slice(0, length);
const decode = (bytes: number[]) => {
let text = "";
for (const byte of bytes) {
if (byte === 0x50) break;
text +=
byte >= 0x80 && byte < 0x9a
? String.fromCharCode(65 + byte - 0x80)
: "?";
}
return text;
};
const allSpecies = Array.from({ length: 251 }, (_, i) => i + 1);
const breedable = () =>
allSpecies.filter((s) => data().baseData(s).eggGroups !== 0xff);
const groupsOf = (species: number) => {
const groups = data().baseData(species).eggGroups;
return [groups & 15, groups >> 4];
};
/** A random pair that shares an egg group (or has a Ditto or a repeat). */
const pairOfSpecies = (): [number, number] => {
const first = pick(breedable());
const choice = next(8);
if (choice === 0) return [first, first];
if (choice === 1) return [first, DITTO];
if (choice === 2) return [DITTO, first];
const partners = breedable().filter((candidate) =>
groupsOf(candidate).some((group) => groupsOf(first).includes(group)),
);
return [first, pick(partners)];
};
const tmhm = Array.from({ length: 57 }, (_, i) => i).flatMap((i) => {
const move = data().reader.byte(
JOHTO.EggMovePointers - JOHTO.EggMovePointers + 0x1167a + i,
);
return move ? [move] : [];
});
/** Moves that may be inherited, to make the inheritance rules matter. */
const movePool = (baby: number) => [
...data().eggMoves(baby),
...data()
.learnset(baby)
.map((entry) => entry.move),
...tmhm,
...Array.from({ length: 6 }, () => 1 + next(251)),
];
const randomMoves = (pool: number[]): BoxMon["moves"] => {
const count = next(5);
const moves = Array.from({ length: count }, () => pick(pool));
const unique = moves.filter((move, i) => moves.indexOf(move) === i);
return [...unique, 0, 0, 0, 0].slice(0, 4) as BoxMon["moves"];
};
const stored = (
species: number,
moves: BoxMon["moves"],
extra: Partial<BoxMon> = {},
): BoxMon => {
const growth = data().baseData(species).growthRate;
const level = 2 + next(60);
return {
species,
item: 0,
moves,
otId: next(65536),
ot: "KAWA",
nickname: "MON",
exp: data().expAtLevel(growth, level) + next(300),
statExp: [0, 1, 2, 3, 4].map(() => next(65536)) as BoxMon["statExp"],
dvs: next(65536),
pp: moves.map((move) =>
move ? (next(4) << 6) | (1 + next(40)) : 0,
) as BoxMon["pp"],
happiness: next(256),
pokerus: next(256),
caughtData: next(65536),
level,
...extra,
};
};
test("CheckBreedmonCompatibility", async ({ page }) => {
const wBreedMon1 = at("wBreedMon1"),
wBreedMon2 = at("wBreedMon2"),
result = at("wBreedingCompatibility");
const pairs = Array.from({ length: 1500 }, () => {
const [first, second] =
next(5) === 0
? ([pick(allSpecies), pick(allSpecies)] as [number, number])
: pairOfSpecies();
const mon1 = stored(first, [0, 0, 0, 0]),
mon2 = stored(second, [0, 0, 0, 0]);
// Equal IDs, Defense DVs and Special DVs make the special bands matter.
if (next(3) === 0) mon2.otId = mon1.otId;
if (next(4) === 0) mon2.dvs = (mon2.dvs & 0xf0f8) | (mon1.dvs & 0x0f07);
if (next(8) === 0) mon2.dvs = (mon2.dvs & 0x0fff) | (mon1.dvs & 0xf000);
return { mon1, mon2 };
});
await openReference(page);
const runs = await runReference(
page,
job(
"CheckBreedmonCompatibility",
pairs.map(({ mon1, mon2 }) => ({
writes: [
...set(wBreedMon1, boxBytes(mon1)),
...set(wBreedMon2, boxBytes(mon2)),
],
reads: [[result, 1]],
})),
[],
),
);
const seen = new Set<number>();
pairs.forEach(({ mon1, mon2 }, index) => {
expect(runs[index].returned, `pair ${index} returned`).toBe(true);
const native = breedingCompatibility(data(), mon1, mon2);
expect(native, `pair ${index}: ${mon1.species}/${mon2.species}`).toBe(
runs[index].reads[0][0],
);
seen.add(native);
});
// 0, 255, 254, 177, 128 and 51 all occur.
expect([...seen].sort((a, b) => a - b)).toEqual([
0, 51, 128, 177, 254, 255,
]);
});
test("GetBreedMon1LevelGrowth", async ({ page }) => {
const wBreedMon1 = at("wBreedMon1");
const cases = Array.from({ length: 600 }, () => {
const species = pick(allSpecies);
const mon = stored(species, [0, 0, 0, 0]);
const growth = data().baseData(species).growthRate;
const extra =
next(4) === 0 ? 0 : next(3) === 0 ? 1 << 20 : next(100) * 2000;
mon.exp = Math.min(
0xffffff,
data().expAtLevel(growth, 1 + next(100)) + extra - next(3),
);
mon.exp = Math.max(0, mon.exp);
return mon;
});
await openReference(page);
const runs = await runReference(
page,
job(
"GetBreedMon1LevelGrowth",
cases.map((mon) => ({
writes: set(wBreedMon1, boxBytes(mon)),
reads: [],
})),
[],
{ registers: true },
),
);
cases.forEach((mon, index) => {
expect(
runs[index].returned,
`case ${index}: ${JSON.stringify(mon)}`,
).toBe(true);
const { oldLevel, level, growth } = breedLevelGrowth(data(), mon);
expect({ oldLevel, growth, level }, `case ${index}`).toEqual({
oldLevel: runs[index].registers[0],
growth: runs[index].registers[1],
level: runs[index].registers[2],
});
});
});
test("DayCare_InitBreeding: the egg, its species, DVs and inherited moves", async ({
page,
}) => {
const sym = {
man: at("wDayCareMan"),
lady: at("wDayCareLady"),
mon1: at("wBreedMon1"),
mon2: at("wBreedMon2"),
steps: at("wStepsToEgg"),
mother: at("wBreedMotherOrNonDitto"),
egg: at("wEggMon"),
eggNickname: at("wEggMonNickname"),
eggOT: at("wEggMonOT"),
playerId: at("wPlayerID"),
playerName: at("wPlayerName"),
};
const player = { id: 0, name: "KAWA" };
const cases = Array.from({ length: 900 }, (_, index) => {
let [first, second] = pairOfSpecies();
// Nidoran♀ and the baby forms, with the partners that matter.
if (index % 9 === 0) [first, second] = [29, pick([32, 33, 34, DITTO])];
if (index % 9 === 1) [first, second] = [pick([32, 33, 34, DITTO]), 29];
if (index % 9 === 2)
[first, second] = [pick([25, 26, 82, 129, 130]), DITTO];
const baby = data().preEvolution(
data().preEvolution(next(2) ? first : second),
);
const pool = movePool(baby);
const mon1 = stored(first, randomMoves(pool)),
mon2 = stored(second, randomMoves(pool));
if (next(3) === 0) mon2.otId = mon1.otId;
if (next(10) === 0) mon2.dvs = (mon2.dvs & 0xf0f8) | (mon1.dvs & 0x0f07);
const stream = Array.from({ length: 80 }, () => next(256));
return {
mon1,
mon2,
stream,
id: next(65536),
prior: { steps: next(256), mother: next(2), man: next(2) << 5 },
};
});
await openReference(page);
// One job per stream: the stream is part of the job. Batched by case.
for (let start = 0; start < cases.length; start += 60) {
const batch = cases.slice(start, start + 60);
const runs: Run[] = [];
for (const input of batch) {
const [run] = await runReference(
page,
job(
"DayCare_InitBreeding",
[
{
writes: [
...set(sym.man, [1 | input.prior.man]),
...set(sym.lady, [1]),
...set(sym.mon1, boxBytes(input.mon1)),
...set(sym.mon2, boxBytes(input.mon2)),
...set(sym.steps, [input.prior.steps]),
...set(sym.mother, [input.prior.mother]),
...set(sym.playerId, word(input.id)),
...set(sym.playerName, name(player.name)),
// Leftovers the routine must overwrite.
...set(sym.egg, Array(BOX_STRUCT).fill(0xaa)),
],
reads: [
[sym.man, 1],
[sym.steps, 1],
[sym.mother, 1],
[sym.egg, BOX_STRUCT],
[sym.eggNickname, NAME],
[sym.eggOT, NAME],
],
},
],
input.stream,
),
);
runs.push(run);
}
batch.forEach((input, offset) => {
const label = `case ${start + offset}: ${input.mon1.species}/${input.mon2.species}`;
const run = runs[offset];
expect(run.returned, label).toBe(true);
const used = [...input.stream];
let draws = 0;
const host = {
flags: new Set([
ENGINE_FLAGS.dayCareManHasMon,
ENGINE_FLAGS.dayCareLadyHasMon,
]),
dayCare: {
...emptyDayCare(),
mon1: structuredClone(input.mon1),
mon2: structuredClone(input.mon2),
stepsToEgg: input.prior.steps,
motherOrNonDitto: input.prior.mother,
compatible: Boolean(input.prior.man),
},
};
initBreeding(
data(),
host,
{ id: input.id, name: player.name },
() => used[draws++],
);
const [man, steps, mother, egg, eggNickname, eggOT] = run.reads;
expect(draws, `${label}: draws`).toBe(run.draws);
expect(Boolean(man[0] & 0x20), `${label}: compatible`).toBe(
host.dayCare.compatible,
);
expect(steps[0], `${label}: steps`).toBe(host.dayCare.stepsToEgg);
expect(mother[0], `${label}: mother`).toBe(
host.dayCare.motherOrNonDitto,
);
if (host.dayCare.egg) {
expect(egg, `${label}: egg`).toEqual(boxBytes(host.dayCare.egg));
expect(decode(eggNickname), label).toBe(host.dayCare.egg.nickname);
expect(decode(eggOT), label).toBe(host.dayCare.egg.ot);
} else {
// Incompatible: the routine returned before touching the egg.
expect(egg, `${label}: egg untouched`).toEqual(
Array(BOX_STRUCT).fill(0xaa),
);
}
});
}
});
test("DayCareStep: experience, the step count and the egg chance", async ({
page,
}) => {
const sym = {
man: at("wDayCareMan"),
lady: at("wDayCareLady"),
mon1: at("wBreedMon1"),
mon2: at("wBreedMon2"),
steps: at("wStepsToEgg"),
};
const cases = Array.from({ length: 1200 }, () => {
const [first, second] = pairOfSpecies();
const mon1 = stored(first, [0, 0, 0, 0]),
mon2 = stored(second, [0, 0, 0, 0]);
if (next(3) === 0) mon2.otId = mon1.otId;
if (next(8) === 0) mon2.dvs = (mon2.dvs & 0xf0f8) | (mon1.dvs & 0x0f07);
// Experience on a byte boundary, levels at the cap.
for (const mon of [mon1, mon2]) {
if (next(3) === 0) mon.exp = (mon.exp & 0xffff00) | 0xff;
if (next(6) === 0) mon.exp = (mon.exp & 0xff0000) | 0xffff;
if (next(8) === 0) mon.exp = (0x4f << 16) | 0xffff;
if (next(8) === 0) mon.level = 100;
if (next(10) === 0) mon.level = 99;
}
const hasMon1 = next(5) !== 0,
hasMon2 = next(5) !== 0;
return {
mon1,
mon2,
hasMon1,
hasMon2,
compatible: next(4) !== 0,
steps: next(3) === 0 ? 1 : next(256),
stream: Array.from({ length: 4 }, () => next(256)),
};
});
await openReference(page);
for (const [index, input] of cases.entries()) {
const [run] = await runReference(
page,
job(
"DayCareStep",
[
{
writes: [
...set(sym.man, [
(input.hasMon1 ? 1 : 0) | (input.compatible ? 0x20 : 0),
]),
...set(sym.lady, [input.hasMon2 ? 1 : 0]),
...set(sym.mon1, boxBytes(input.mon1)),
...set(sym.mon2, boxBytes(input.mon2)),
...set(sym.steps, [input.steps]),
],
reads: [
[sym.man, 1],
[sym.steps, 1],
[sym.mon1 + 8, 3],
[sym.mon2 + 8, 3],
],
},
],
input.stream,
),
);
expect(run.returned, `case ${index}`).toBe(true);
const used = [...input.stream];
let draws = 0;
const flags = new Set<number>();
if (input.hasMon1) flags.add(ENGINE_FLAGS.dayCareManHasMon);
if (input.hasMon2) flags.add(ENGINE_FLAGS.dayCareLadyHasMon);
const dayCare = {
...emptyDayCare(),
mon1: structuredClone(input.mon1),
mon2: structuredClone(input.mon2),
stepsToEgg: input.steps,
compatible: input.compatible,
};
dayCareStep(data(), { flags, dayCare }, () => used[draws++]);
const [man, steps, exp1, exp2] = run.reads;
const label = `case ${index}: ${JSON.stringify({ ...input, stream: input.stream })}`;
expect(exp1, label).toEqual([
(dayCare.mon1.exp >> 16) & 0xff,
(dayCare.mon1.exp >> 8) & 0xff,
dayCare.mon1.exp & 0xff,
]);
expect(exp2, label).toEqual([
(dayCare.mon2.exp >> 16) & 0xff,
(dayCare.mon2.exp >> 8) & 0xff,
dayCare.mon2.exp & 0xff,
]);
expect(steps[0], label).toBe(dayCare.stepsToEgg);
expect(
{ compatible: Boolean(man[0] & 0x20), egg: Boolean(man[0] & 0x40) },
label,
).toEqual({
compatible: dayCare.compatible,
egg: flags.has(ENGINE_FLAGS.dayCareManHasEgg),
});
expect(draws, label).toBe(run.draws);
}
});
test("RetrieveBreedmon: stats, learned moves, PP, HP and experience", async ({
page,
}) => {
const sym = {
count: at("wPartyCount"),
species: at("wPartySpecies"),
party: at("wPartyMon1"),
nicknames: at("wPartyMonNicknames"),
ots: at("wPartyMonOTs"),
mon1: at("wBreedMon1"),
nickname1: at("wBreedMon1Nickname"),
ot1: at("wBreedMon1OT"),
parameter: at("wPokemonWithdrawDepositParameter"),
level: at("wCurPartyLevel"),
previous: at("wPrevPartyLevel"),
curSpecies: at("wCurPartySpecies"),
skip: at("wSkipMovesBeforeLevelUp"),
};
const cases = Array.from({ length: 700 }, () => {
const species = pick(allSpecies);
const learnLevels = data()
.learnset(species)
.map((entry) => entry.level);
const moves = randomMoves(movePool(species));
const mon = stored(species, moves);
// Level up across learnset entries about half the time.
const oldLevel = next(2)
? 1 + next(40)
: Math.max(1, (pick(learnLevels) ?? 10) - next(3));
const newLevel = Math.min(100, oldLevel + (next(4) === 0 ? 0 : next(30)));
mon.level = oldLevel;
mon.exp =
data().expAtLevel(data().baseData(species).growthRate, newLevel) +
next(500);
return { mon, oldLevel, newLevel, count: next(6) };
});
await openReference(page);
for (const [index, input] of cases.entries()) {
const slot = input.count;
const [run] = await runReference(
page,
job(
"RetrieveBreedmon",
[
{
writes: [
...set(sym.count, [slot]),
...set(sym.mon1, boxBytes(input.mon)),
...set(sym.nickname1, name("NICKNAME")),
...set(sym.ot1, name("OTNAME")),
...set(sym.parameter, [0]),
...set(sym.level, [input.newLevel]),
...set(sym.previous, [input.oldLevel]),
...set(sym.curSpecies, [input.mon.species]),
// Leftovers in the slot, as an earlier occupant leaves.
...set(
sym.party + slot * PARTY_STRUCT,
Array(PARTY_STRUCT).fill(0x5a),
),
],
reads: [
[sym.count, 1],
[sym.species + slot, 2],
[sym.party + slot * PARTY_STRUCT, PARTY_STRUCT],
[sym.nicknames + slot * NAME, NAME],
[sym.ots + slot * NAME, NAME],
],
},
],
[],
),
);
const label = `case ${index}: ${JSON.stringify(input)}`;
expect(run.returned, label).toBe(true);
const mon = retrievedMon(
data(),
{ ...input.mon, nickname: "NICKNAME", ot: "OTNAME" },
input.oldLevel,
input.newLevel,
);
const [count, species, struct, nickname, ot] = run.reads;
expect(count[0], label).toBe(slot + 1);
expect(species, label).toEqual([input.mon.species, 0xff]);
expect(decode(nickname), label).toBe("NICKNAME");
expect(decode(ot), label).toBe("OTNAME");
const word16 = (value: number) => word(value);
const expected = [
...boxBytes(mon),
mon.status,
0,
...word16(mon.hp),
...mon.stats.flatMap(word16),
];
// The unused byte after the status is whatever the slot held; the
// routine does not clear it (HealPartyMon writes it as 0).
expect(struct, label).toEqual(expected);
}
});
test("_GiveOddEgg: the egg chosen by the random word and its party entry", async ({
page,
}) => {
const sym = {
count: at("wPartyCount"),
species: at("wPartySpecies"),
party: at("wPartyMon1"),
nicknames: at("wPartyMonNicknames"),
ots: at("wPartyMonOTs"),
};
// Each OddEggProbabilities entry, and the words either side of it.
const reader = data().reader;
const thresholds = Array.from({ length: 14 }, (_, i) => {
const entry = JOHTO.OddEggProbabilities + i * 2;
return reader.byte(entry) | (reader.byte(entry + 1) << 8);
});
const words = [
...thresholds.flatMap((value) => [value - 1, value, value + 1]),
0,
0xffff,
...Array.from({ length: 300 }, () => next(65536)),
]
.filter((value) => value >= 0 && value <= 0xffff)
.map((value) => [value >> 8, value & 0xff]);
await openReference(page);
const seenSpecies = new Set<number>();
// The map is reached with a nonempty party. AddMobileMonToParty
// assumes that: its decrement loop underflows for an empty party.
for (const [index, [sub, add]] of words.entries()) {
const [run] = await runReference(
page,
job(
"_GiveOddEgg",
[
{
writes: set(sym.count, [1 + (index % 4)]),
reads: [
[sym.count, 1],
[sym.species, 6],
[sym.party + (1 + (index % 4)) * PARTY_STRUCT, PARTY_STRUCT],
[sym.nicknames + (1 + (index % 4)) * NAME, NAME],
[sym.ots + (1 + (index % 4)) * NAME, NAME],
],
},
],
[sub, add],
{ random: { address: routine("Random").address, bytesPerCall: 2 } },
),
);
const label = `word ${sub.toString(16)}${add.toString(16)}`;
expect(run.returned, label).toBe(true);
expect(run.draws, label).toBe(2);
const slot = 1 + (index % 4);
const [count, species, struct, nickname, ot] = run.reads;
const used = [sub, add];
let draws = 0;
const egg = oddEgg(data(), () => used[draws++]);
seenSpecies.add(egg.species);
expect(draws, label).toBe(2);
expect(count[0], label).toBe(slot + 1);
expect(species[slot], label).toBe(0xfd);
const expected = [
egg.species,
egg.item,
...egg.moves,
...word(egg.otId),
(egg.exp >> 16) & 0xff,
...word(egg.exp),
...egg.statExp.flatMap(word),
...word(egg.dvs),
...egg.pp,
egg.happiness,
egg.pokerus,
...word(egg.caughtData),
egg.level,
egg.status,
0,
...word(egg.hp),
...egg.stats.flatMap(word),
];
expect(struct, label).toEqual(expected);
expect(decode(nickname), label).toBe(egg.nickname);
expect(decode(ot), label).toBe(egg.ot);
}
// Pichu, Cleffa, Igglybuff, Smoochum, Magby, Elekid and Tyrogue.
expect([...seenSpecies].sort((a, b) => a - b)).toEqual([
172, 173, 174, 236, 238, 239, 240,
]);
});
});
+233
View File
@@ -0,0 +1,233 @@
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.",
);
test("Hall of Fame PC reads SRAM and matches the resting tilemap, picture and palette", async ({
page,
}) => {
const { at, routine } = loadSymbols();
const flat = (name: string) => {
const { bank, address } = routine(name);
return address < 0x4000 ? address : bank * 0x4000 + address - 0x4000;
};
const addresses = {
romBank: at("hROMBank"),
cgb: at("hCGB"),
joy: at("hJoyLast"),
tilemap: at("wTilemap"),
attrs: at("wAttrmap"),
palettes: at("wBGPals1"),
frame: at("wTextboxFrame"),
hof: at("sHallOfFame"),
};
const pc = routine("_HallOfFamePC");
const font = routine("_LoadStandardFont");
// Only bypass frame waits and frontpic animation: the native viewer
// presents the resting image. All SRAM decoding, layout, text, gender,
// frontpic decompression and palette selection execute cartridge code.
const skip = [
"WaitBGMap",
"DelayFrame",
"HOF_AnimateFrontpic",
"JoyTextDelay",
].map(flat);
await openReference(page);
const result = await page.evaluate(
async ({ addresses: a, pc, font, skip }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { hallOfFameScreen } =
await import("/src/game/scripts/hall-of-fame.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;
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 cases = [
{
species: 25,
dvs: 0xaaaa,
level: 42,
nickname: "SPARK",
otId: 12345,
win: 1,
},
{
species: 201,
dvs: 0xffff,
level: 100,
nickname: "UNOWN",
otId: 0,
win: 200,
},
{
species: 29,
dvs: 0,
level: 5,
nickname: "NIDORAN♀",
otId: 65535,
win: 99,
},
{
species: 157,
dvs: 0xffff,
level: 99,
nickname: "FIRE",
otId: 42,
win: 100,
},
...Array.from({ length: 251 }, (_, i) => ({
species: i + 1,
dvs: i % 2 ? 0x2aaa : 0xffff,
level: 1 + (i % 100),
nickname: "RECORDED",
otId: i * 251,
win: 1 + (i % 200),
})),
];
const failures: unknown[] = [];
for (const [index, input] of cases.entries()) {
const { win, ...mon } = input;
const team = { win, mons: [mon] };
// SRAM bank 1: one team, a terminator after its first Pokémon, and
// the absent next team. The PC must load this itself.
write(0, 0x0a);
write(0x4000, 1);
for (let i = 0; i < 196; i++) write(a.hof + i, 0);
const record = [
win,
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),
0xff,
];
record.forEach((value, offset) => write(a.hof + offset, value));
write(0, 0);
write(0xff70, 1);
write(0xff40, 0);
write(a.cgb, 1);
write(a.frame, index % 8);
write(a.joy, 2); // B after the first page.
// JoyTextDelay is stubbed to preserve the fixed B input above.
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0x3e,
font.bank,
0xe0,
a.romBank & 255,
0xea,
0,
0x20,
0xcd,
font.address & 255,
font.address >> 8,
0x3e,
pc.bank,
0xe0,
a.romBank & 255,
0xea,
0,
0x20,
0xcd,
pc.address & 255,
pc.address >> 8,
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 < 100 && read(0xc0f0) !== 0x42; frame++)
reference.advanceFrames(1);
const native = hallOfFameScreen(assets.pokemon, team, 0, index % 8);
const original = Array.from({ length: 360 }, (_, i) =>
read(a.tilemap + i),
);
const mismatches = original.flatMap((tile, i) =>
tile === native.map[i]
? []
: [{ i, original: tile, native: native.map[i] }],
);
const attrDiff = Array.from({ length: 360 }, (_, i) =>
read(a.attrs + i),
).filter((v, i) => v !== native.attrs[i]).length;
// CGB palette work RAM is bank 5; graphics at $9000 are VRAM bank 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 picture = Array.from({ length: 49 * 16 }, (_, i) =>
read(0x9000 + i),
);
const picDiff = picture.filter((v, i) => v !== native.tiles[i]).length;
const tileDiff = [...new Set(native.map)]
.flatMap((tile) =>
Array.from({ length: 16 }, (_, byte) => ({
tile,
byte,
expected: native.tiles[tile * 16 + byte],
actual: read(
(tile < 128 ? 0x9000 : 0x8800) + (tile % 128) * 16 + byte,
),
})),
)
.filter(({ expected, actual }) => expected !== actual);
if (
read(0xc0f0) !== 0x42 ||
mismatches.length ||
attrDiff ||
picDiff ||
tileDiff.length ||
JSON.stringify(palette) !== JSON.stringify(native.palettes[0])
)
failures.push({
input,
returned: read(0xc0f0) === 0x42,
mismatches: mismatches.slice(0, 12),
attrDiff,
picDiff,
tileDiff: tileDiff.slice(0, 5),
palette,
nativePalette: native.palettes[0],
});
}
reference.dispose();
return { cases: cases.length, failures };
},
{ addresses, pc, font, skip },
);
expect(result.failures, JSON.stringify(result)).toEqual([]);
});
+346
View File
@@ -0,0 +1,346 @@
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.",
);
for (const [species, dvs] of [
[175, 0x8888],
[172, 0x2aaa],
])
test(`hatch ${species}/${dvs} wobble and shell frames match original OAM, tilemaps and palettes`, async ({
page,
}) => {
test.setTimeout(240_000);
const { at, routine } = loadSymbols();
const names = [
"wNamedObjectIndex",
"wCurSpecies",
"wCurPartySpecies",
"wCurPartyMon",
"wPartyMon1DVs",
"wBoxAlignment",
"hSCX",
"wGlobalAnimXOffset",
"wFrameCounter",
"wShadowOAM",
"wBGPals1",
"wBGPals2",
"wOBPals1",
"wOBPals2",
"hCGB",
"hBGMapMode",
"hVBlank",
"hROMBank",
"wPartyCount",
"wPartySpecies",
"wTimeOfDay",
"wPlayerHPPal",
"wTilemap",
"wAttrmap",
"wSpriteAnimationStructs",
"wSpriteAnimCount",
];
const a: Record<string, number> = {};
for (const n of names) a[n] = at(n);
a.DelayFrame = routine("DelayFrame").address;
const seq = routine("EggHatch_AnimationSequence");
await openReference(page);
const result = await page.evaluate(
async ({ a, seq, species, dvs }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const rom = new Uint8Array(
await (
document.querySelector("#reference-fixture") as HTMLInputElement
).files![0].arrayBuffer(),
);
// Count DelayFrame calls: its first five bytes (ld a, 1; ld [wVBlankOccurred], a)
// jump to a hook in the unused end of bank 0 that bumps a counter at $c6f1.
const expected = [0x3e, 0x01, 0xea, 0xb3, 0xcf, 0x76];
expected.forEach((v, i) => {
if (rom[0x45a + i] !== v)
throw Error("DelayFrame is not where expected");
});
[0xc3, 0xc0, 0x3f, 0x00, 0x00].forEach((v, i) => (rom[0x45a + i] = v));
[
0xfa, 0xf1, 0xc6, 0x3c, 0xea, 0xf1, 0xc6, 0x3e, 0x01, 0xea, 0xb3,
0xcf, 0xc3, 0x5f, 0x04,
].forEach((v, i) => (rom[0x3fc0 + i] = v));
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { hatchAnimation, shellSprites, hatchScreen } =
await import("/src/game/scripts/hatch-animation.ts");
const assets = new CrystalAssets(rom);
const { SYMBOLS } = await import("/src/game/rom/profile.ts");
const animation = hatchAnimation(assets.pokemon, species, dvs);
const native: {
map: number[];
attrs: number[];
oam: number[];
scx: number;
palette: number[][];
}[] = [];
const oam = (
sprites: {
x: number;
y: number;
tile: number;
flipX?: boolean;
flipY?: boolean;
palette: number;
}[],
) => {
const bytes = Array<number>(160).fill(0);
sprites.forEach((s, i) =>
bytes.splice(
i * 4,
4,
(s.y + 16) & 255,
(s.x + 8) & 255,
s.tile - 256,
s.palette | (s.flipX ? 32 : 0) | (s.flipY ? 64 : 0),
),
);
return bytes;
};
for (const effect of animation)
if (effect.kind === "screen")
native.push({
map: [...effect.screen.map],
attrs: [...effect.screen.attrs],
oam: oam(effect.screen.sprites),
scx: (effect.screen.scroll?.x ?? 0) & 255,
palette: effect.screen.palettes[0],
});
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const write = (p: number, v: number) =>
reference.writeMemory(p, v & 255);
const read = (p: number) => reference.readMemory(p);
// Boot to the intro so the game's Init has run.
reference.advanceFrames(240);
// _CGB_Evolution keeps OBJ palettes 0/1 from the overworld.
// Seed daylight palettes exactly as LoadMapPals would.
write(0xff70, 5);
for (let i = 0; i < 16; i++)
write(
0xd040 + i,
assets.reader.byte(SYMBOLS.objectPalettes + 64 + i),
);
write(0xff70, 1);
write(a.hVBlank, 0);
write(a.wNamedObjectIndex, species);
write(a.wCurSpecies, species);
write(a.wCurPartySpecies, species);
write(a.wCurPartyMon, 0);
write(a.wPartyCount, 1);
write(a.wPartySpecies, species);
write(a.wPartySpecies + 1, 0xff);
write(a.wPartyMon1DVs, dvs >> 8);
write(a.wPartyMon1DVs + 1, dvs);
write(a.wBoxAlignment, 0);
write(a.hBGMapMode, 0);
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0x3e,
seq.bank,
0xe0,
a.hROMBank & 255,
0xea,
0,
0x20,
0xfb, // EI
0xcd,
seq.address & 255,
seq.address >> 8,
0xf3,
0x3e,
0x42,
0xea,
0xf0,
0xc6,
0x18,
0xfe,
];
write(0xc6f0, 0);
program.forEach((v, i) => write(0xc700 + i, v));
// Run the real animation, sampling its DelayFrame calls.
reference.setProgramCounter(0xc700);
const frames: Record<string, unknown>[] = [];
const log: string[] = [];
// One sample per DelayFrame call: within 100 cycles of the counter changing.
let lastCount = read(0xc6f1);
const nextDelayFrame = () => {
for (let guard = 0; guard < 200000; guard++) {
reference.advanceCycles(100);
const count = read(0xc6f1);
if (count !== lastCount) {
lastCount = count;
return true;
}
if (read(0xc6f0) === 0x42) return false;
}
return false;
};
for (let f = 0; f < 1200; f++) {
if (!nextDelayFrame()) {
log.push(`no DelayFrame at ${f}`);
break;
}
const done = read(0xc6f0) === 0x42;
const oam: number[] = [];
for (let i = 0; i < 160; i++) oam.push(read(a.wShadowOAM + i));
const map: number[] = [];
for (let i = 0; i < 360; i++) map.push(read(a.wTilemap + i));
const attrs: number[] = [];
for (let i = 0; i < 360; i++) attrs.push(read(a.wAttrmap + i));
const bg: number[] = [],
ob: number[] = [],
bg2: number[] = [],
ob2: number[] = [];
write(0xff70, 5);
for (let i = 0; i < 64; i++) {
bg.push(read(0xd000 + i));
ob.push(read(0xd040 + i));
bg2.push(read(0xd080 + i));
ob2.push(read(0xd0c0 + i));
}
write(0xff70, 1);
const structs: number[] = [];
for (let i = 0; i < 160; i++)
structs.push(read(a.wSpriteAnimationStructs + i));
const rec = {
f,
structs,
bg2,
ob2,
ly: read(0xff44),
scx: read(0xff43),
hscx: read(a.hSCX),
gx: read(a.wGlobalAnimXOffset),
fc: read(a.wFrameCounter),
lcdc: read(0xff40),
bgmode: read(a.hBGMapMode),
oam,
map,
attrs,
bg,
ob,
done,
};
frames.push(rec);
if (done) {
log.push(`done at ${f}`);
break;
}
}
reference.dispose();
const errors: unknown[] = [];
const eq = (
actual: unknown,
expected: unknown,
phase: string,
frame: number,
) => {
if (
JSON.stringify(actual) !== JSON.stringify(expected) &&
errors.length < 5
)
errors.push({ phase, frame, actual, expected });
};
const trace = frames as {
f: number;
fc: number;
hscx: number;
oam: number[];
map: number[];
attrs: number[];
bg: number[];
structs: number[];
}[];
const start = trace.findIndex(
(f) => f.fc === 1 && f.hscx === 2 && f.map[87] === 49,
);
if (start < 0) throw Error("No wobble began");
for (let frame = 0; frame < 344; frame++) {
const ref = trace[start + frame],
local = native[80 + frame];
eq(ref.oam, local.oam, "wobble OAM", frame);
eq(ref.map, local.map, "wobble tilemap", frame);
eq(ref.attrs, local.attrs, "wobble attrs", frame);
eq(ref.hscx, local.scx, "wobble scroll", frame);
}
let comparedShell = 0;
for (const ref of trace) {
if (ref.fc !== 9 || ref.structs[1] !== 32) continue;
const var1 = ref.structs[12];
if (!var1) continue;
const frame = ref.structs[0] ? var1 / 8 - 1 : 16;
if (frame > 16) continue;
// Once removed, the following frame clears OAM while VAR1 stays 128.
if (!ref.structs[0] && !ref.oam[0]) continue;
eq(
ref.oam,
oam(shellSprites(assets.pokemon, frame)),
"shell OAM",
frame,
);
comparedShell++;
}
const final = trace.find((f) => f.fc === 9 && f.map[66] === 0);
if (!final) throw Error("No hatchling appeared");
const screen = hatchScreen(assets.pokemon, species, dvs, true);
eq(final.map, screen.map, "hatchling tilemap", 0);
eq(final.attrs, screen.attrs, "hatchling attrs", 0);
const rgb = (lo: number, hi: number) => {
const n = lo | (hi << 8);
return [
Math.round(((n & 31) * 255) / 31),
Math.round((((n >> 5) & 31) * 255) / 31),
Math.round((((n >> 10) & 31) * 255) / 31),
];
};
eq(
Array.from({ length: 4 }, (_, i) =>
rgb(final.bg[i * 2], final.bg[i * 2 + 1]),
),
screen.palettes[0],
"hatchling palette",
0,
);
eq(
Array.from({ length: 4 }, (_, i) =>
rgb(trace[start].bg[i * 2], trace[start].bg[i * 2 + 1]),
),
native[80].palette,
"egg palette",
0,
);
const originalObj = (final as unknown as { ob: number[] }).ob;
eq(
Array.from({ length: 8 }, (_, p) =>
Array.from({ length: 4 }, (_, i) =>
rgb(originalObj[p * 8 + i * 2], originalObj[p * 8 + i * 2 + 1]),
),
),
screen.objPalettes,
"object palettes",
0,
);
return { start, comparedWobble: 344, comparedShell, errors };
},
{ a, seq, species, dvs },
);
expect(result.comparedShell).toBeGreaterThanOrEqual(17);
expect(result.errors, JSON.stringify(result)).toEqual([]);
});
+201
View File
@@ -0,0 +1,201 @@
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.",
);
test("all ten MAIL screens match cartridge tilemaps, graphics, message placement and palettes", async ({
page,
}) => {
const { at, routine } = loadSymbols();
const labels = [
"Flower",
"Surf",
"LiteBlue",
"Portrait",
"Lovely",
"Eon",
"Morph",
"BlueSky",
"Music",
"Mirage",
];
const defs = labels.map((name) => routine(`Load${name}MailGFX`));
const a = Object.fromEntries(
[
"hROMBank",
"hCGB",
"wTilemap",
"wAttrmap",
"wCurPartySpecies",
"wUnownLetter",
"wCurMailIndex",
"wBoxAlignment",
"wBGPals1",
"sPartyMon1Mail",
"sPartyMon1MailAuthor",
].map((n) => [n, at(n)]),
);
const skip = ["ApplyPals", "ApplyAttrmap"].map(routine);
const palette = routine("LoadMailPalettes");
await openReference(page);
const result = await page.evaluate(
async ({ defs, a, skip, palette }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { mailScreen } = await import("/src/game/scripts/mail.ts");
const { textTiles } = await import("/src/game/rom/font.ts");
const { MAIL } = await import("/src/game/rom/profile.ts");
const rom = new Uint8Array(
await (
document.querySelector("#reference-fixture") as HTMLInputElement
).files![0].arrayBuffer(),
);
const assets = new CrystalAssets(rom);
// Exclude hardware palette transfer only. The original graphics,
// message/author placement and palette selection execute.
for (const { bank, address } of skip)
rom[bank * 0x4000 + address - 0x4000] = 0xc9;
const ref = new BinjgbEmulator();
await ref.load(rom, document.createElement("canvas"));
const write = (p: number, v: number) => ref.writeMemory(p, v & 255);
const read = (p: number) => ref.readMemory(p);
const call = (routine: { bank: number; address: number }, e = 0) => {
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0xaf,
0xe0,
0x40,
0xe0,
0x4f,
0x3e,
routine.bank,
0xe0,
a.hROMBank & 255,
0xea,
0,
0x20,
0x01,
a.sPartyMon1Mail & 255,
a.sPartyMon1Mail >> 8,
0x1e,
e,
0xcd,
routine.address & 255,
routine.address >> 8,
0x3e,
0x42,
0xea,
0xf0,
0xc0,
0x18,
0xfe,
];
write(0xc0f0, 0);
program.forEach((v, i) => write(0xc000 + i, v));
ref.setProgramCounter(0xc000);
for (let f = 0; f < 60 && read(0xc0f0) !== 0x42; f++)
ref.advanceFrames(1);
if (read(0xc0f0) !== 0x42)
throw Error(
`MAIL routine ${routine.address.toString(16)} did not return`,
);
};
const failures: unknown[] = [];
for (let index = 0; index < defs.length; index++) {
write(0xff40, 0);
write(0xff4f, 0);
write(0xff70, 1);
write(a.hCGB, 1);
for (let p = 0x8000; p < 0xa000; p++) write(p, 0);
for (let i = 0; i < 360; i++) write(a.wTilemap + i, 0x7f);
write(a.wCurMailIndex, index);
write(a.wBoxAlignment, 0);
write(a.wCurPartySpecies, 201);
write(a.wUnownLetter, 1);
const message = "“HELLO…” FRIEND! ";
write(0x0000, 0x0a);
write(0x4000, 0);
const bytes = [
...textTiles(message),
0x4e,
...textTiles("SEE YOU SOON!"),
0x50,
];
for (let i = 0; i < 47; i++) write(a.sPartyMon1Mail + i, 0x50);
bytes.forEach((v, i) => write(a.sPartyMon1Mail + i, v));
textTiles("CHRIS").forEach((v, i) =>
write(a.sPartyMon1MailAuthor + i, v),
);
call(defs[index]);
const native = mailScreen(assets.pokemon, {
message: message + "\nSEE YOU SOON!",
author: "CHRIS",
authorId: 1,
species: 201,
type: assets.reader.byte(MAIL.MailGFXPointers + index * 3),
});
const map = Array.from({ length: 360 }, (_, i) => read(a.wTilemap + i));
const mapDiff = map.flatMap((v, i) =>
v === native.map[i]
? []
: [{ i, original: v, native: native.map[i] }],
);
const tileDiff: unknown[] = [];
for (const tile of new Set(map)) {
if (tile >= 0x60) continue; // font is loaded by ReadAnyMail, before these routines
for (let b = 0; b < 16; b++)
if (read(0x9000 + tile * 16 + b) !== native.tiles[tile * 16 + b]) {
tileDiff.push({
tile,
b,
original: read(0x9000 + tile * 16 + b),
native: native.tiles[tile * 16 + b],
});
break;
}
}
call(palette, index);
write(0xff70, 5);
const colors = Array.from(
{ length: 4 },
(_, i) =>
read(a.wBGPals1 + i * 2) | (read(a.wBGPals1 + i * 2 + 1) << 8),
);
const rgb = colors.map((v) =>
[v & 31, (v >> 5) & 31, (v >> 10) & 31].map((c) =>
Math.round((c * 255) / 31),
),
);
write(0xff70, 1);
if (
mapDiff.length ||
tileDiff.length ||
JSON.stringify(rgb) !== JSON.stringify(native.palettes[0])
)
failures.push({
index,
mapDiff: mapDiff.slice(0, 12),
tileDiff: tileDiff.slice(0, 12),
rgb,
nativePalette: native.palettes[0],
});
}
ref.dispose();
return { count: defs.length, failures };
},
{ defs, a, skip, palette },
);
expect(result.count).toBe(10);
expect(result.failures).toEqual([]);
});
+8
View File
@@ -84,6 +84,14 @@ describe.skipIf(!existsSync(romPath))("Tin Tower and Whirl Islands with the loca
world.state.party.pop();
world.state.party.push(mon(world, ENTEI, { ot: name, otId: world.playerId ^ 1 }));
expect(beasts()).toEqual([false, true]);
// Preserve CheckOwnMonAnywhere's Day-Care omission: our own Entei
// in either keeper's care still does not satisfy MonCheck.
world.state.party.pop();
for (const [slot, flag] of [["mon1", 6], ["mon2", 7]] as const) {
world.state.dayCare[slot] = toBoxMon(mon(world, ENTEI, { ot: name }));
world.state.flags.add(flag);
expect(beasts()).toEqual([false, true]);
}
// Without any party Pokémon, CheckOwnMonAnywhere finds nothing.
world.state.party = [];
expect(beasts()).toEqual([true, true]);
+10
View File
@@ -20,6 +20,10 @@ export function settle(
if (dialogue) {
const page = dialogue.pages[dialogue.index];
const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.choice?.kind === "screen" && dialogue.choice.realtime) {
world.tick();
continue;
}
if (last && dialogue.choice) {
shown.push(page);
// The naming screen waits for the caller unless a name is queued.
@@ -28,6 +32,12 @@ export function settle(
world.choose(answers.shift()!);
continue;
}
if (dialogue.choice.kind === "screen") {
shown.push(dialogue.choice.screen.label);
if (typeof answers[0] !== "string") return shown;
world.choose(answers.shift()!);
continue;
}
const answer = answers.shift();
world.choose(
typeof answer === "number"