Files
Crystal/tests/reference-game-corner.spec.ts
kawaandClaude Opus 5.5 b5c2b19094
Checks / web (push) Successful in 1m46s
Private ROM checks / private-rom (push) Failing after 39m32s
Open Routes 38 to 41, Olivine and Cianwood to the Mineral Badge (#32)
Enable Route 38 and its Ecruteak gate, Route 39 with the MooMoo farm,
Olivine City and its buildings, the Lighthouse's six floors, Routes 40
and 41, and Cianwood City with its gym, pharmacy, Photo Studio, Mania's
house, the Poke Seer and the Lugia speech house. Olivine's port, the
Battle Tower gate and Whirl Islands stay closed. The story runs from the
rival in Olivine through Jasmine and Amphy, Suicune and Eusine, the
SecretPotion, Chuck's boulders, the STORMBADGE, TM01 and HM02, back to
Amphy's cure, Jasmine, the MINERALBADGE and TM23.

Port GiveShuckle and ReturnShuckie with every return code and Mania's
wrong last-Pokemon speech, PokeSeer with its high-byte-only OT check and
reused caught level, PhotoStudio's no-printer path (Printer Error 2),
and PlaySlowCry. wMooMooBerries joins the saved script bytes.

Port trainer item AI: the 13 healing, status and X items, the
highest-level gate, item use while Mean Look or Wrap prevents
switching, and the original's random and state quirks, checked against
the ROM over 5,632 states. Leaders with items are no longer refused.

Game Corner sprites match the original frame by frame: cursor and reel
positions, Golem's whole-frame flips, frame timing, the frame a removed
sprite is still drawn, and the Golem roll's screen shake. The Burned
Tower rival's garbage trainer header is documented as a boundary: its
seen text dispatches text command $7f into home code before any battle.

The playthrough continues for six stages to Jasmine; its driver can
Surf, fish and travel through floors joined by holes. New tests cover
trainer items, Olivine and Cianwood side content, and reference checks
for trainer items, trainer sight and Game Corner animations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:09:47 +02:00

481 lines
24 KiB
TypeScript

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("CardFlip_UpdateCursorOAM matches cursorSprites for every cursor position", async ({ page }) => {
const { at, routine } = loadSymbols();
const a = {
romBank: at("hROMBank"),
cgb: at("hCGB"),
cursorX: at("wCardFlipCursorX"),
cursorY: at("wCardFlipCursorY"),
shadowOAM: at("wShadowOAMSprite00"),
};
const updateCursorOAM = routine("CardFlip_UpdateCursorOAM");
await openReference(page);
const result = await page.evaluate(async ({ a, updateCursorOAM }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { cursorSprites } = await import("/src/game/scripts/card-flip.ts");
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
const assets = new CrystalAssets(rom);
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
const read = (p: number) => reference.readMemory(p);
// DI; SP=$c7fe; CGB mode so the blink check is skipped, as the port's
// own comment assumes; CALL CardFlip_UpdateCursorOAM; sentinel.
const program = [
0xf3, 0x31, 0xfe, 0xc7,
0x3e, updateCursorOAM.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
0xcd, updateCursorOAM.address & 255, updateCursorOAM.address >> 8,
0x3e, 0x42, 0xea, 0xf0, 0xc0,
0x18, 0xfe,
];
const OBJ_BASE = 0x100;
// ScreenSprite.x/y are plain screen pixels (tile-screen.ts draws them
// with the same col*8/row*8 convention as background tiles); hardware
// OAM bytes are 8 (x) and 16 (y) beyond that (see unown-puzzle.ts's own
// "OAM coordinates are 16 (y) and 8 (x) beyond the screen's").
const OAM_Y_OFFSET = 16,
OAM_X_OFFSET = 8;
const failures: unknown[] = [];
let compared = 0;
// Every cell ChooseCard_HandleJoypad's grid can address (single tiles,
// number/Pokémon rows, columns and the paired groups), all 48 of them.
for (let cursorY = 0; cursorY < 8; cursorY++)
for (let cursorX = 0; cursorX < 6; cursorX++) {
write(a.cursorX, cursorX);
write(a.cursorY, cursorY);
write(a.cgb, 1);
write(0xc0f0, 0);
program.forEach((v, i) => write(0xc000 + i, v));
reference.setProgramCounter(0xc000);
for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
if (read(0xc0f0) !== 0x42) throw Error("CardFlip_UpdateCursorOAM did not return");
const original: number[] = [];
for (let i = 0; i < 40 * 4; i++) original.push(read(a.shadowOAM + i));
const sprites = cursorSprites(assets.reader, cursorX, cursorY);
const native = new Array(160).fill(0);
sprites.forEach((sprite, i) => {
native[i * 4 + 0] = (sprite.y + OAM_Y_OFFSET) & 0xff;
native[i * 4 + 1] = (sprite.x + OAM_X_OFFSET) & 0xff;
native[i * 4 + 2] = (sprite.tile - OBJ_BASE) & 0xff;
native[i * 4 + 3] = (sprite.palette & 7) | (sprite.flipX ? 0x20 : 0) | (sprite.flipY ? 0x40 : 0) | (sprite.behind ? 0x80 : 0);
});
if (original.some((v, i) => v !== native[i]))
failures.push({ cursorX, cursorY, count: sprites.length, original, native });
compared++;
}
reference.dispose();
return { compared, failures: failures.slice(0, 3), count: failures.length };
}, { a, updateCursorOAM });
expect(result.compared).toBe(48);
expect(result, JSON.stringify(result)).toMatchObject({ count: 0 });
});
test("Slots_UpdateReelPositionAndOAM/.SpinReel match reelSprites for reel positions and spin distances", async ({ page }) => {
const { at, routine } = loadSymbols();
// RomReader addresses (as JOHTO.Reel1Tilemap etc. are generated) are flat
// ROM-file offsets, bank * $4000 + (address - $4000); CPU addresses
// written into the reel struct for the ROM's own code to dereference are
// the plain 16-bit address instead.
const flat = ({ bank, address }: { bank: number; address: number }) => bank * 0x4000 + (address - 0x4000);
const reel1Tilemap = routine("Reel1Tilemap"),
reel2Tilemap = routine("Reel2Tilemap"),
reel3Tilemap = routine("Reel3Tilemap");
const a = {
romBank: at("hROMBank"),
wReel1: at("wReel1"),
wReel2: at("wReel2"),
wReel3: at("wReel3"),
reel1Tilemap: reel1Tilemap.address,
reel2Tilemap: reel2Tilemap.address,
reel3Tilemap: reel3Tilemap.address,
reel1TilemapFlat: flat(reel1Tilemap),
reel2TilemapFlat: flat(reel2Tilemap),
reel3TilemapFlat: flat(reel3Tilemap),
shadow16: at("wShadowOAMSprite16"),
shadow24: at("wShadowOAMSprite24"),
shadow32: at("wShadowOAMSprite32"),
};
const updateReel = routine("Slots_UpdateReelPositionAndOAM");
const spinReel = routine("Slots_SpinReels.SpinReel");
await openReference(page);
const result = await page.evaluate(async ({ a, updateReel, spinReel }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { readReelTilemap, makeReel, reelSprites } = await import("/src/game/scripts/slot-machine.ts");
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
const assets = new CrystalAssets(rom);
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
const read = (p: number) => reference.readMemory(p);
// struct fields (engine/games/slot_machine.asm): REEL_ACTION=0,
// REEL_TILEMAP_ADDR=1(w), REEL_POSITION=3, REEL_SPIN_DISTANCE=4,
// REEL_SPIN_RATE=5, REEL_OAM_ADDR=6(w), REEL_X_COORD=8.
const call = (bank: number, address: number, bc: number) => {
const program = [
0xf3, 0x31, 0xfe, 0xc7,
0x3e, bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
0x01, bc & 255, bc >> 8,
0xcd, address & 255, address >> 8,
0x3e, 0x42, 0xea, 0xf0, 0xc0,
0x18, 0xfe,
];
write(0xc0f0, 0);
program.forEach((v, i) => write(0xc000 + i, v));
reference.setProgramCounter(0xc000);
for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
if (read(0xc0f0) !== 0x42) throw Error("reel routine did not return");
};
const SYMBOL_BASE_TILE = 0x25;
// .LoadOAM writes one hardware 8x16 sprite per column (2 per row, tile
// auto-pairing its own bottom half); reelSprites instead draws each as
// two separate 8x8 ScreenSprites (dy 0 and 1). Keep only the dy=0
// (top) halves, at the even positions of each row's four pushes, to
// compare against the ROM's own 8 real OAM entries.
const toNative = (sprites: { x: number; y: number; tile: number; palette: number }[]) => {
const tops = sprites.filter((_, i) => i % 2 === 0);
const oam = new Array(32).fill(0);
tops.forEach((sprite, i) => {
oam[i * 4 + 0] = (sprite.y + 16) & 0xff;
oam[i * 4 + 1] = (sprite.x + 8) & 0xff;
oam[i * 4 + 2] = (sprite.tile - SYMBOL_BASE_TILE) & 0xff;
oam[i * 4 + 3] = (sprite.palette & 7) | 0x80; // B_OAM_PRIO, always set
});
return oam;
};
const defs = [
{ wReel: a.wReel1, tilemapAddr: a.reel1Tilemap, tilemapFlat: a.reel1TilemapFlat, shadow: a.shadow16, xTile: 6 },
{ wReel: a.wReel2, tilemapAddr: a.reel2Tilemap, tilemapFlat: a.reel2TilemapFlat, shadow: a.shadow24, xTile: 10 },
{ wReel: a.wReel3, tilemapAddr: a.reel3Tilemap, tilemapFlat: a.reel3TilemapFlat, shadow: a.shadow32, xTile: 14 },
];
const failures: unknown[] = [];
let compared = 0;
for (const def of defs) {
const tilemap = readReelTilemap(assets.reader, def.tilemapFlat);
for (const position of [0, 5, 14]) {
write(def.wReel + 1, def.tilemapAddr & 255);
write(def.wReel + 2, def.tilemapAddr >> 8);
write(def.wReel + 3, position);
write(def.wReel + 6, def.shadow & 255);
write(def.wReel + 7, def.shadow >> 8);
write(def.wReel + 8, def.xTile * 8);
call(updateReel.bank, updateReel.address, def.wReel);
const original: number[] = [];
for (let i = 0; i < 32; i++) original.push(read(def.shadow + i));
const reel = makeReel(tilemap);
reel.position = position;
const native = toNative(reelSprites(reel, def.xTile));
if (original.some((v, i) => v !== native[i]))
failures.push({ reel: def.xTile, position, original, native });
compared++;
}
}
// Spin distance: one real .SpinReel tick from a freshly redrawn (subY=0)
// reel 1, with the low nibble of the resulting distance left nonzero so
// it takes the partial, Y-bytes-only shift path instead of a full
// redraw.
for (const rate of [5, 11]) {
write(a.wReel1 + 0, 0); // REEL_ACTION_DO_NOTHING (so .SpinReel's own ReelActionJumptable call is a no-op)
write(a.wReel1 + 1, a.reel1Tilemap & 255);
write(a.wReel1 + 2, a.reel1Tilemap >> 8);
write(a.wReel1 + 3, 5);
write(a.wReel1 + 6, a.shadow16 & 255);
write(a.wReel1 + 7, a.shadow16 >> 8);
write(a.wReel1 + 8, 6 * 8);
call(updateReel.bank, updateReel.address, a.wReel1);
write(a.wReel1 + 4, 0); // REEL_SPIN_DISTANCE
write(a.wReel1 + 5, rate); // REEL_SPIN_RATE
call(spinReel.bank, spinReel.address, a.wReel1);
const original: number[] = [];
for (let i = 0; i < 32; i++) original.push(read(a.shadow16 + i));
const tilemap = readReelTilemap(assets.reader, a.reel1TilemapFlat);
const reel = makeReel(tilemap);
reel.position = 5;
reel.spinDistance = rate;
const native = toNative(reelSprites(reel, 6));
if (original.some((v, i) => v !== native[i])) failures.push({ rate, original, native });
compared++;
}
reference.dispose();
return { compared, failures: failures.slice(0, 3), count: failures.length };
}, { a, updateReel, spinReel });
expect(result.compared).toBe(11);
expect(result, JSON.stringify(result)).toMatchObject({ count: 0 });
});
test("the sprite-animation engine's Golem, Chansey and Egg OAM match golemSprites/chanseySprites/eggSprites", async ({ page }) => {
const { at, routine } = loadSymbols();
const a = {
romBank: at("hROMBank"),
structs: at("wSpriteAnimationStructs"),
count: at("wSpriteAnimCount"),
oamAddr: at("wCurSpriteOAMAddr"),
shadow: at("wShadowOAM"),
globalX: at("wGlobalAnimXOffset"),
globalY: at("wGlobalAnimYOffset"),
};
const initStruct = routine("_InitSpriteAnimStruct");
const updateFrame = routine("UpdateAnimFrame");
const animateGolem = routine("Slots_AnimateGolem");
const animateChansey = routine("Slots_AnimateChansey");
const animateEgg = routine("SpriteAnimFunc_SlotsChanseyEgg");
await openReference(page);
const result = await page.evaluate(async ({ a, initStruct, updateFrame, animateGolem, animateChansey, animateEgg }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { initGolem, stepGolem, golemSprites, initChansey, stepChansey, chanseySprites, stepEgg, eggSprites } = await import(
"/src/game/scripts/slot-machine.ts"
);
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
const assets = new CrystalAssets(rom);
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
const read = (p: number) => reference.readMemory(p);
const call = (bank: number, address: number, program: number[]) => {
const full = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, ...program, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
write(0xc0f0, 0);
full.forEach((v, i) => write(0xc000 + i, v));
reference.setProgramCounter(0xc000);
for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
if (read(0xc0f0) !== 0x42) throw Error("sprite anim routine did not return");
};
// SPRITEANIMSTRUCT field offsets (constants/sprite_anim_constants.asm).
const VAR3 = 14;
const failures: unknown[] = [];
let compared = 0;
function run(animId: number, d: number, e: number, animate: { bank: number; address: number }, extra?: () => void) {
for (let i = 0; i < 160; i++) write(a.structs + i, 0);
write(a.count, 0);
write(a.oamAddr, 0);
for (let i = 0; i < 32; i++) write(a.shadow + i, 0);
// wGlobalAnimXOffset/YOffset are an untracked engine-global leftover,
// like the OBJ palette applySlotsAttrs' own doc comment describes;
// UpdateAnimFrame adds them into every OAM byte it writes, so pin
// them at 0 here to compare the engine's own per-sprite math cleanly.
write(a.globalX, 0);
write(a.globalY, 0);
// LD DE, de; LD A, animId; CALL _InitSpriteAnimStruct.
call(initStruct.bank, initStruct.address, [0x11, e, d, 0x3e, animId]);
if (extra) extra();
// The struct lands at wSpriteAnimationStructs (the only free slot);
// CALL animate(bc=struct); CALL UpdateAnimFrame(bc=struct) to draw it.
call(animate.bank, animate.address, [0x01, a.structs & 255, a.structs >> 8]);
call(updateFrame.bank, updateFrame.address, [0x01, a.structs & 255, a.structs >> 8]);
const oam: number[] = [];
for (let i = 0; i < 32; i++) oam.push(read(a.shadow + i));
return oam;
}
// Bit 4 of the attribute byte is set in several of the ROM's own OAM
// tables (e.g. $15, $16) but is not a real CGB OBJ attribute bit
// (palette 0-2, bank 3, XFLIP 5, YFLIP 6, PRIO 7): it has no effect on
// hardware, and golemSprites/chanseySprites/eggSprites do not keep it.
// Masked out here rather than reproduced.
const ATTR_MASK = 0xe7;
const toNative = (sprites: { x: number; y: number; tile: number; palette: number; flipX?: boolean; flipY?: boolean }[]) => {
const oam = new Array(32).fill(0);
sprites.forEach((sprite, i) => {
oam[i * 4 + 0] = (sprite.y + 16) & 0xff;
oam[i * 4 + 1] = (sprite.x + 8) & 0xff;
// Only CREATURE_BASE_TILE (this port's own merged-tile-layout
// rebasing) comes off; GOLEM_VTILE/CHANSEY_VTILE/EGG_VTILE already
// equal the ROM's own per-animal TILE_ID (GetSpriteAnimVTile).
oam[i * 4 + 2] = (sprite.tile - 0x100) & 0xff;
oam[i * 4 + 3] = ((sprite.palette & 7) | (sprite.flipX ? 0x20 : 0) | (sprite.flipY ? 0x40 : 0)) & ATTR_MASK;
});
return oam;
};
const maskAttrs = (oam: number[]) => oam.map((v, i) => (i % 4 === 3 ? v & ATTR_MASK : v));
// Golem: depixel 12, 13 -> D=$60, E=$68; VAR3=1 so .init falls through
// .retain into .fall with VAR1=$30 (matching initGolem(session, 1)),
// computing the first real sine step (one stepGolem tick).
{
const session: { golem?: never } = {};
initGolem(session as never, 1);
const original = maskAttrs(run(6, 0x60, 0x68, animateGolem, () => write(a.structs + VAR3, 1)));
stepGolem(session as never, assets.reader);
const native = toNative(golemSprites(assets.reader, session.golem!));
if (original.some((v, i) => v !== native[i])) failures.push({ which: "golem", original, native });
compared++;
}
// Chansey: depixel 12, 0 -> D=$60, E=$00; a single `.walk` tick (the
// state Slots_AnimateChansey's own jumptable index 0 starts at).
{
const session: { chansey?: never } = {};
initChansey(session as never);
const original = maskAttrs(run(7, 0x60, 0x00, animateChansey));
stepChansey(session as never);
const native = toNative(chanseySprites(assets.reader, session.chansey!));
if (original.some((v, i) => v !== native[i])) failures.push({ which: "chansey", original, native });
compared++;
}
// Egg: depixel 12, 13, 0, 4 -> D=$60, E=$6c; SpriteAnimFunc_SlotsChanseyEgg
// dispatches on JUMPTABLE_INDEX's own parity (not a jumptable), so it
// is directly callable the same way. There is no ReelAction_InitChansey-
// style helper for just the egg, so its state is built by hand from the
// same depixel values (`.spawn_egg`'s own `session.egg = {...}`).
{
const egg = { counter: 0, xCoord: 0x6c, yOffset: 0 };
const original = maskAttrs(run(8, 0x60, 0x6c, animateEgg));
stepEgg({ egg } as never, assets.reader);
const native = toNative(eggSprites(assets.reader, egg));
if (original.some((v, i) => v !== native[i])) failures.push({ which: "egg", original, native });
compared++;
}
reference.dispose();
return { compared, failures, count: failures.length };
}, { a, initStruct, updateFrame, animateGolem, animateChansey, animateEgg });
expect(result.compared).toBe(3);
expect(result, JSON.stringify(result)).toMatchObject({ count: 0 });
});
test("PlaySpriteAnimations' Golem, Chansey and Egg sequences match the native steps frame by frame", async ({ page }) => {
const { at, routine } = loadSymbols();
const a = {
romBank: at("hROMBank"),
structs: at("wSpriteAnimationStructs"),
count: at("wSpriteAnimCount"),
shadow: at("wShadowOAM"),
globalX: at("wGlobalAnimXOffset"),
globalY: at("wGlobalAnimYOffset"),
delay: at("wSlotsDelay"),
scy: at("hSCY"),
};
const initStruct = routine("_InitSpriteAnimStruct");
const playAnimations = routine("PlaySpriteAnimations");
await openReference(page);
const result = await page.evaluate(async ({ a, initStruct, playAnimations }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const slots = await import("/src/game/scripts/slot-machine.ts");
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
const assets = new CrystalAssets(rom);
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
const read = (p: number) => reference.readMemory(p);
const call = (bank: number, address: number, program: number[] = []) => {
const full = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, ...program, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
write(0xc0f0, 0);
full.forEach((v, i) => write(0xc000 + i, v));
reference.setProgramCounter(0xc000);
for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
if (read(0xc0f0) !== 0x42) throw Error("sprite anim routine did not return");
};
// See the first-frame test above: attribute bit 4 is not a CGB OBJ bit.
const ATTR_MASK = 0xe7;
type Sprite = { x: number; y: number; tile: number; palette: number; flipX?: boolean; flipY?: boolean };
const toNative = (sprites: Sprite[]) => {
const oam = new Array(160).fill(0);
sprites.forEach((sprite, i) => {
oam[i * 4 + 0] = (sprite.y + 16) & 0xff;
oam[i * 4 + 1] = (sprite.x + 8) & 0xff;
oam[i * 4 + 2] = (sprite.tile - 0x100) & 0xff;
oam[i * 4 + 3] = ((sprite.palette & 7) | (sprite.flipX ? 0x20 : 0) | (sprite.flipY ? 0x40 : 0)) & ATTR_MASK;
});
return oam;
};
type Session = Parameters<typeof slots.stepGolem>[0];
const failures: unknown[] = [];
let compared = 0;
/** Runs both sides tick by tick. `drive` applies the reel actions'
* wSlotsDelay writes to both, between ticks; it returns false to stop. */
function sequence(
which: string,
animId: number,
d: number,
e: number,
setup: (session: Session) => void,
var3: number | undefined,
drive: (tick: number, delay: number, session: Session) => number | false,
) {
for (let i = 0; i < 160; i++) write(a.structs + i, 0);
write(a.count, 0);
write(a.globalX, 0);
write(a.globalY, 0);
write(a.delay, 0);
write(a.scy, 0);
call(initStruct.bank, initStruct.address, [0x11, e, d, 0x3e, animId]);
if (var3 !== undefined) write(a.structs + 14, var3); // SPRITEANIMSTRUCT_VAR3
const session = { delay: 0 } as Session;
setup(session);
for (let tick = 0; tick < 1000; tick++) {
call(playAnimations.bank, playAnimations.address);
slots.stepGolem(session, assets.reader);
slots.stepChansey(session);
slots.stepEgg(session, assets.reader);
const original: number[] = [];
for (let i = 0; i < 160; i++) original.push(i % 4 === 3 ? read(a.shadow + i) & ATTR_MASK : read(a.shadow + i));
const sprites: Sprite[] = [];
if (session.golem) sprites.push(...slots.golemSprites(assets.reader, session.golem));
if (session.chansey) sprites.push(...slots.chanseySprites(assets.reader, session.chansey));
if (session.egg) sprites.push(...slots.eggSprites(assets.reader, session.egg));
const native = toNative(sprites);
const delay = read(a.delay);
if (delay !== session.delay) failures.push({ which, tick, delay, native: session.delay });
else if (read(a.scy) !== (session.scy ?? 0)) failures.push({ which, tick, scy: read(a.scy), native: session.scy });
else if (original.some((v, i) => v !== native[i]))
failures.push({ which, tick, original: original.slice(0, 32), native: native.slice(0, 32) });
compared++;
if (failures.length) return tick;
const next = drive(tick, delay, session);
if (next === false) return tick;
write(a.delay, next);
session.delay = next;
}
throw Error(`${which} did not finish`);
}
// Golem: three throws. ReelAction_WaitGolem/EndGolem answer each landing
// (wSlotsDelay 1) by clearing it again a few ticks later; the sequence
// ends two ticks after .init's last-Golem branch (wSlotsDelay 2).
let landed = -1, finished = -1;
const golemTicks = sequence("golem", 6, 0x60, 0x68, (s) => slots.initGolem(s, 3), 3, (tick, delay) => {
if (delay === 1 && landed < 0) landed = tick;
if (landed >= 0 && tick === landed + 4) { landed = -1; return 0; }
if (delay === 2 && finished < 0) finished = tick;
if (finished >= 0 && tick === finished + 2) return false;
return delay;
});
// Chansey: walks in (wSlotsDelay 1), ReelAction_WaitChansey answers with
// 2, the egg rolls out (4), DropReel's EggAgain asks for another (1, then
// 2 again), and the second egg ends the round (5, Chansey removed).
let eggs = 0, at = -1, done = -1;
const chanseyTicks = sequence("chansey", 7, 0x60, 0x00, (s) => slots.initChansey(s), undefined, (tick, delay) => {
if (delay === 1 && at < 0) at = tick;
if (delay === 1 && tick === at + 3) { at = -1; return 2; }
if (delay === 4) {
if (at < 0) at = tick;
if (tick === at + 17) {
at = -1;
return ++eggs === 1 ? 1 : 5;
}
}
if (delay === 5 && done < 0) done = tick;
if (done >= 0 && tick === done + 2) return false;
return delay;
});
reference.dispose();
return { compared, golemTicks, chanseyTicks, eggs, failures: failures.slice(0, 3), count: failures.length };
}, { a, initStruct, playAnimations });
expect(result, JSON.stringify(result)).toMatchObject({ count: 0, eggs: 2 });
// Three throws of 17 falling and 37 rolling ticks each, plus the last .init.
expect(result.golemTicks).toBe(167);
expect(result.chanseyTicks).toBe(216);
expect(result.compared).toBe(168 + 217);
});