Files
Crystal/tests/reference-day-care.spec.ts
T
kawaandClaude Opus 5.5 e49bc6de9a 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>
2026-10-17 08:52:30 +02:00

817 lines
27 KiB
TypeScript

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,
]);
});
});