Files
Crystal/tests/reference-day-care.spec.ts
kawaandClaude Opus 5.5 d3e3faff72
Checks / web (push) Failing after 19s
Private ROM checks / private-rom (push) Failing after 27m7s
Measure, weigh and name the European releases' own way (#84)
The French, German, Italian and Spanish releases store Magikarp's
length in millimetres (and print a tenth of them), start the Magikarp
house's record at their own length and holder, keep Pokédex heights in
decimetres and weights in hectograms (printed in metres and kilograms),
read the Heavy Ball's weight byte without converting it, and place the
enemy's nickname before EnemyText (French, Italian) or the trainer's
name before the class (French, Italian, Spanish).

map-revision --symbols writes each release's symbol file, so the
reference specs run against a release; specs that pin English data skip
there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:04:51 +02:00

827 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, seeded by each test from
// its own title (FNV-1a): a test's cases are the same whichever tests ran
// before it in the worker, and in every browser.
let seed = 0;
test.beforeEach(({}, testInfo) => {
seed = 0x811c9dc5;
for (const character of testInfo.title)
seed = Math.imul(seed ^ character.charCodeAt(0), 0x01000193) >>> 0;
});
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;
// Capitals and small letters (a release's own names use both).
text +=
byte >= 0x80 && byte < 0x9a
? String.fromCharCode(65 + byte - 0x80)
: byte >= 0xa0 && byte < 0xba
? String.fromCharCode(97 + byte - 0xa0)
: "?";
}
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,
]);
});
});