Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s
Checks / web (push) Canceled after 0s
This commit is contained in:
1 parent
8b80d05ca2
commit
c08d250cf6
39 files changed
+10727
-1524
No files matched your search
@@ -1,5 +1,9 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { BattleData } from "../src/game/battle/data";
|
||||
import { applyTypeMatchups, calculateDamage } from "../src/game/battle/battle";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const romPath = resolve(
|
||||
@@ -104,3 +108,188 @@ test("native decompression matches original GB assembly executing in the referen
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("native damage and type effectiveness match the original battle routines", async ({
|
||||
page,
|
||||
}) => {
|
||||
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
|
||||
test.skip(
|
||||
!existsSync(romPath) || !existsSync(symbolsPath),
|
||||
"Requires the local user ROM and the private PRET rebuild.",
|
||||
);
|
||||
const symbols = new Map(
|
||||
readFileSync(symbolsPath, "utf8")
|
||||
.split("\n")
|
||||
.flatMap((line) => {
|
||||
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
|
||||
return match
|
||||
? [[match[3], { bank: parseInt(match[1], 16), address: parseInt(match[2], 16) }] as const]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
const at = (name: string) => symbols.get(name)!.address;
|
||||
const routine = (name: string) => symbols.get(name)!;
|
||||
const bytes = new Uint8Array(readFileSync(romPath));
|
||||
const assets = new CrystalAssets(bytes);
|
||||
const data = new BattleData(assets.reader);
|
||||
const matchups = data.typeMatchups();
|
||||
// A deterministic generator keeps failures reproducible.
|
||||
let seed = 0x1234;
|
||||
const next = (limit: number) => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 8) % limit;
|
||||
};
|
||||
const types = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 24, 25, 26, 27];
|
||||
const effects = [0, 0, 0, C.EFFECT_SELFDESTRUCT, C.EFFECT_MULTI_HIT, C.EFFECT_CONVERSION];
|
||||
const damageCases = Array.from({ length: 400 }, () => {
|
||||
const item = next(4) === 0 ? next(191) : 0;
|
||||
return {
|
||||
power: next(8) === 0 ? 0 : next(256),
|
||||
level: 1 + next(100),
|
||||
attack: next(256),
|
||||
defense: next(256),
|
||||
effect: effects[next(effects.length)],
|
||||
critical: next(2),
|
||||
current: next(5) === 0 ? next(1200) : 0,
|
||||
type: types[next(types.length)],
|
||||
item,
|
||||
};
|
||||
});
|
||||
const stabCases = Array.from({ length: 400 }, () => ({
|
||||
move: 1 + next(164),
|
||||
type: types[next(types.length)],
|
||||
user: [types[next(types.length)], types[next(types.length)]],
|
||||
target: [types[next(types.length)], types[next(types.length)]],
|
||||
identified: next(4) === 0,
|
||||
damage: next(1000),
|
||||
}));
|
||||
await page.goto("/reference.html");
|
||||
await page.evaluate(() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.id = "reference-fixture";
|
||||
document.body.append(input);
|
||||
});
|
||||
await page.locator("#reference-fixture").setInputFiles(romPath);
|
||||
const addresses = {
|
||||
curDamage: at("wCurDamage"),
|
||||
critical: at("wCriticalHit"),
|
||||
turn: at("hBattleTurn"),
|
||||
playerMove: at("wPlayerMoveStruct"),
|
||||
battleItem: at("wBattleMonItem"),
|
||||
battleTypes: at("wBattleMonType1"),
|
||||
enemyTypes: at("wEnemyMonType1"),
|
||||
typeModifier: at("wTypeModifier"),
|
||||
attackMissed: at("wAttackMissed"),
|
||||
weather: at("wBattleWeather"),
|
||||
johto: at("wJohtoBadges"),
|
||||
kanto: at("wKantoBadges"),
|
||||
link: at("wLinkMode"),
|
||||
tower: at("wInBattleTowerBattle"),
|
||||
enemySub1: at("wEnemySubStatus1"),
|
||||
damageCalc: routine("BattleCommand_DamageCalc"),
|
||||
stab: routine("BattleCommand_Stab"),
|
||||
};
|
||||
const results = await page.evaluate(
|
||||
async ({ addresses, damageCases, stabCases }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0];
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(new Uint8Array(await file.arrayBuffer()), document.createElement("canvas"));
|
||||
const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff);
|
||||
const word = (address: number, value: number) => {
|
||||
write(address, value >> 8);
|
||||
write(address + 1, value);
|
||||
};
|
||||
// Trampoline: DI; SP=$c7fe; switch to the routine's bank; set B, C, D, E;
|
||||
// CALL the original routine; write a sentinel; loop.
|
||||
const call = (target: { bank: number; address: number }, registers: number[]) => {
|
||||
const program = [
|
||||
0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20,
|
||||
0x06, registers[0], 0x0e, registers[1], 0x16, registers[2], 0x1e, registers[3],
|
||||
0xcd, target.address & 0xff, target.address >> 8,
|
||||
0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe,
|
||||
];
|
||||
write(0xc6ff, 0);
|
||||
program.forEach((value, index) => write(0xc000 + index, value));
|
||||
reference.setProgramCounter(0xc000);
|
||||
reference.advanceFrames(2);
|
||||
return reference.readMemory(0xc6ff) === 0x42;
|
||||
};
|
||||
write(0xff70, 1); // WRAM bank 1 holds wCurDamage and wEnemyMon
|
||||
for (const address of [addresses.weather, addresses.johto, addresses.kanto, addresses.link, addresses.tower])
|
||||
write(address, 0);
|
||||
write(addresses.turn, 0);
|
||||
const damage = damageCases.map((input) => {
|
||||
write(addresses.playerMove + 1, input.effect);
|
||||
write(addresses.playerMove + 3, input.type);
|
||||
write(addresses.battleItem, input.item);
|
||||
write(addresses.critical, input.critical);
|
||||
word(addresses.curDamage, input.current);
|
||||
// B = attack, C = defense, D = power, E = level
|
||||
const returned = call(addresses.damageCalc, [input.attack, input.defense, input.power, input.level]);
|
||||
return {
|
||||
returned,
|
||||
damage: (reference.readMemory(addresses.curDamage) << 8) | reference.readMemory(addresses.curDamage + 1),
|
||||
};
|
||||
});
|
||||
const stab = stabCases.map((input) => {
|
||||
write(addresses.playerMove, input.move);
|
||||
write(addresses.playerMove + 3, input.type);
|
||||
write(addresses.battleTypes, input.user[0]);
|
||||
write(addresses.battleTypes + 1, input.user[1]);
|
||||
write(addresses.enemyTypes, input.target[0]);
|
||||
write(addresses.enemyTypes + 1, input.target[1]);
|
||||
write(addresses.enemySub1, input.identified ? 1 << 3 : 0);
|
||||
write(addresses.typeModifier, 10);
|
||||
write(addresses.attackMissed, 0);
|
||||
word(addresses.curDamage, input.damage);
|
||||
const returned = call(addresses.stab, [0, 0, 0, 0]);
|
||||
return {
|
||||
returned,
|
||||
damage: (reference.readMemory(addresses.curDamage) << 8) | reference.readMemory(addresses.curDamage + 1),
|
||||
modifier: reference.readMemory(addresses.typeModifier),
|
||||
missed: reference.readMemory(addresses.attackMissed),
|
||||
};
|
||||
});
|
||||
reference.dispose();
|
||||
return { damage, stab };
|
||||
},
|
||||
{ addresses, damageCases, stabCases },
|
||||
);
|
||||
damageCases.forEach((input, index) => {
|
||||
const original = results.damage[index];
|
||||
expect(original.returned, `damage case ${index} returned`).toBe(true);
|
||||
const held = data.heldEffect(input.item);
|
||||
const boost =
|
||||
held.effect && data.typeBoost(held.effect) === input.type ? held.parameter : undefined;
|
||||
const native =
|
||||
calculateDamage({
|
||||
power: input.power,
|
||||
level: input.level,
|
||||
attack: input.attack,
|
||||
defense: input.defense,
|
||||
effect: input.effect,
|
||||
critical: Boolean(input.critical),
|
||||
boost,
|
||||
current: input.current,
|
||||
}) ?? input.current;
|
||||
expect(native, `damage case ${index}: ${JSON.stringify(input)}`).toBe(original.damage);
|
||||
});
|
||||
stabCases.forEach((input, index) => {
|
||||
const original = results.stab[index];
|
||||
expect(original.returned, `type case ${index} returned`).toBe(true);
|
||||
const native = applyTypeMatchups(matchups, {
|
||||
damage: input.damage,
|
||||
modifier: 10,
|
||||
moveType: input.type,
|
||||
userTypes: input.user,
|
||||
targetTypes: input.target,
|
||||
identified: input.identified,
|
||||
});
|
||||
expect(
|
||||
[native.damage, native.modifier, Number(native.missed)],
|
||||
`type case ${index}: ${JSON.stringify(input)}`,
|
||||
).toEqual([original.damage, original.modifier, original.missed]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user