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:
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
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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];
|
||||
|
||||
@@ -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!"]);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user