Port the moves that span several turns

A recharging, charged, rampaging or rolling Pokémon plays on without the
battle menu on either side, and a biding one uses FIGHT without the move
list. Razor Wind, Solarbeam, Skull Bash, Sky Attack, Fly and Dig charge
first; flying and digging Pokémon can only be hit by the original's
exceptions. Hyper Beam recharges, Thrash and its kind end in confusion,
Rollout and Fury Cutter double up, and Bide returns twice the damage it
took.

The trapping moves hurt their target between turns until it is released,
Mind Reader and Lock-On make the next move hit, and Pursuit strikes a
Pokémon being withdrawn first, on either side.

The new commands are compared with the original in the reference
emulator. Its fixture no longer zeroes wPlayerScreens with its sentinel.

Part of #28

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-03 10:57:46 +02:00
1 parent 9e0a2cd2a8
commit c53edb77e5
7 files changed
+713 -39

No files matched your search

+130 -6
View File
@@ -29,6 +29,10 @@ type Side = {
rollout: number;
damageTaken: number;
lastCounterMove: number;
furyCutter: number;
confuse: number;
wrap: number;
trappingMove: number;
};
type Case = {
turn: number;
@@ -45,6 +49,8 @@ type Case = {
partyCount: number;
otPartyCount: number;
beatUpHit: number;
playerIsSwitching: number;
enemyIsSwitching: number;
/** A battle script and the index of the command after the one run. */
script: number[];
scriptIndex: number;
@@ -77,6 +83,10 @@ function generator(start: number) {
rollout: 0,
damageTaken: 0,
lastCounterMove: 0,
furyCutter: 0,
confuse: 0,
wrap: 0,
trappingMove: 0,
...overrides,
};
};
@@ -95,6 +105,8 @@ function generator(start: number) {
partyCount: 1 + next(6),
otPartyCount: 1 + next(6),
beatUpHit: 1,
playerIsSwitching: 0,
enemyIsSwitching: 0,
script: [COMMAND.critical, COMMAND.endmove],
scriptIndex: 1,
...overrides,
@@ -133,6 +145,8 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
partyCount: at("wPartyCount"),
otPartyCount: at("wOTPartyCount"),
beatUpHit: at("wBeatUpHitAtLeastOnce"),
playerIsSwitching: at("wPlayerIsSwitching"),
enemyIsSwitching: at("wEnemyIsSwitching"),
scriptBuffer: at("wBattleScriptBuffer"),
scriptAddress: at("wBattleScriptBufferAddress"),
sides: [
@@ -146,6 +160,10 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
rollout: at("wPlayerRolloutCount"),
damageTaken: at("wPlayerDamageTaken"),
lastCounterMove: at("wLastPlayerCounterMove"),
furyCutter: at("wPlayerFuryCutterCount"),
confuse: at("wPlayerConfuseCount"),
wrap: at("wPlayerWrapCount"),
trappingMove: at("wPlayerTrappingMove"),
},
{
mon: at("wEnemyMonSpecies"),
@@ -157,6 +175,10 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
rollout: at("wEnemyRolloutCount"),
damageTaken: at("wEnemyDamageTaken"),
lastCounterMove: at("wLastEnemyCounterMove"),
furyCutter: at("wEnemyFuryCutterCount"),
confuse: at("wEnemyConfuseCount"),
wrap: at("wEnemyWrapCount"),
trappingMove: at("wEnemyTrappingMove"),
},
],
};
@@ -182,9 +204,11 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
const read = (address: number) => reference.readMemory(address);
const readWord = (address: number) => (read(address) << 8) | read(address + 1);
// DI; SP=$c7fe; select bank $0d; CALL each command; sentinel; loop.
// The program and its sentinel ($c0f0) sit in audio RAM, which
// nothing reads with interrupts off.
const program = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, 0x0d, 0xe0, addresses.romBank & 255, 0xea, 0x00, 0x20];
for (const { address } of commands) program.push(0xcd, address & 255, address >> 8);
program.push(0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe);
program.push(0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe);
write(0xff70, 1);
write(addresses.link, 1);
const results = cases.map((input, index) => {
@@ -231,6 +255,8 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
animParam: input.animParam,
battleMode: input.battleMode,
beatUpHit: input.beatUpHit,
playerIsSwitching: input.playerIsSwitching,
enemyIsSwitching: input.enemyIsSwitching,
script: [...input.script],
scriptIndex: input.scriptIndex,
});
@@ -246,6 +272,8 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
write(addresses.partyCount, input.partyCount);
write(addresses.otPartyCount, input.otPartyCount);
write(addresses.beatUpHit, input.beatUpHit);
write(addresses.playerIsSwitching, input.playerIsSwitching);
write(addresses.enemyIsSwitching, input.enemyIsSwitching);
write(addresses.count, input.count);
input.seeds.forEach((value, i) => write(addresses.seeds + i, value));
input.script.forEach((value, i) => write(addresses.scriptBuffer + i, value));
@@ -278,6 +306,10 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
side.rolloutCount = s.rollout;
side.damageTaken = s.damageTaken;
side.lastCounterMove = s.lastCounterMove;
side.furyCutterCount = s.furyCutter;
side.confuseCount = s.confuse;
side.wrapCount = s.wrap;
side.trappingMove = s.trappingMove;
const mon = a.mon;
write(mon, s.species);
write(mon + 1, s.item);
@@ -300,16 +332,20 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
write(a.rollout, s.rollout);
word(a.damageTaken, s.damageTaken);
write(a.lastCounterMove, s.lastCounterMove);
write(a.furyCutter, s.furyCutter);
write(a.confuse, s.confuse);
write(a.wrap, s.wrap);
write(a.trappingMove, s.trappingMove);
});
for (const { command } of commands) {
const run = internal.command(command);
if (!run.next().done) throw new Error(`Case ${index} printed a text.`);
}
write(0xc6ff, 0);
write(0xc0f0, 0);
program.forEach((value, i) => write(0xc000 + i, value));
reference.setProgramCounter(0xc000);
for (let frame = 0; frame < 60 && read(0xc6ff) !== 0x42; frame++) reference.advanceFrames(1);
if (read(0xc6ff) !== 0x42) throw new Error(`Case ${index} did not return`);
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
if (read(0xc0f0) !== 0x42) throw new Error(`Case ${index} did not return`);
const native = {
damage: internal.curDamage,
critical: internal.criticalHit,
@@ -323,9 +359,12 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
power: side.move.power,
type: side.move.type,
accuracy: side.move.accuracy,
sub3: side.sub[3],
sub: side.sub.slice(1),
rollout: side.rolloutCount,
damageTaken: side.damageTaken,
furyCutter: side.furyCutterCount,
confuse: side.confuseCount,
wrap: side.wrapCount,
})),
count,
seeds,
@@ -345,9 +384,12 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
power: read(a.move + 2),
type: read(a.move + 3),
accuracy: read(a.move + 4),
sub3: read(a.sub + 2),
sub: [0, 1, 2, 3, 4].map((j) => read(a.sub + j)),
rollout: read(a.rollout),
damageTaken: readWord(a.damageTaken),
furyCutter: read(a.furyCutter),
confuse: read(a.confuse),
wrap: read(a.wrap),
})),
count: read(addresses.count),
seeds: Array.from({ length: 10 }, (_, i) => read(addresses.seeds + i)),
@@ -497,3 +539,85 @@ test("Triple Kick, Hidden Power and False Swipe match their damage changes", asy
}
await compare(page, ["BattleCommand_FalseSwipe"], swipes);
});
test("Rollout, Fury Cutter, rampages, charges and Pursuit match their counters and damage", async ({ page }) => {
const { make, next } = generator(0x2011);
const damage = () => [0, 1, 40, 997, 0x3fff, 0x8000, 0xffff][next(7)];
const rollout: Case[] = [];
for (let i = 0; i < 400; i++) {
const input = make({ curDamage: damage(), attackMissed: next(4) ? 0 : 1 });
const user = input.sides[input.turn];
user.rollout = next(6);
user.status = next(5) ? 0 : 2;
if (next(2)) user.sub[1] = 1 << C.SUBSTATUS_CURLED;
if (next(2)) user.sub[0] = 1 << C.SUBSTATUS_ROLLOUT;
rollout.push(input);
}
await compare(page, ["BattleCommand_RolloutPower"], rollout);
const fury: Case[] = [];
for (let i = 0; i < 300; i++) {
const input = make({ curDamage: damage(), attackMissed: next(4) ? 0 : 1 });
input.sides[input.turn].furyCutter = next(8);
fury.push(input);
}
await compare(page, ["BattleCommand_FuryCutter"], fury);
// checkrampage, checkobedience, doturn, rampage | usedmovetext, endmove
const rampageScript = [COMMAND.checkrampage, COMMAND.checkobedience, COMMAND.doturn, COMMAND.rampage, COMMAND.usedmovetext, COMMAND.endmove];
const rampage: Case[] = [];
for (let i = 0; i < 300; i++) {
const input = make({ script: rampageScript, scriptIndex: 1 });
const user = input.sides[input.turn];
if (next(4)) user.sub[2] = 1 << C.SUBSTATUS_RAMPAGE;
user.rollout = 1 + next(3);
user.confuse = next(6);
if (next(3) === 0) user.screens = 1 << C.SCREENS_SAFEGUARD;
if (next(3) === 0) input.sides[input.turn ^ 1].screens = 1 << C.SCREENS_SAFEGUARD;
rampage.push(input);
}
await compare(page, ["BattleCommand_CheckRampage"], rampage);
const start: Case[] = [];
for (let i = 0; i < 100; i++) {
const input = make();
input.sides[input.turn].status = next(3) ? 0 : 3;
start.push(input);
}
await compare(page, ["BattleCommand_Rampage"], start);
// checkrollout, checkobedience, doturn | usedmovetext, endmove
const checkRollout: Case[] = [];
for (let i = 0; i < 100; i++) {
const input = make({ script: [COMMAND.checkrollout, COMMAND.checkobedience, COMMAND.doturn, COMMAND.usedmovetext, COMMAND.endmove], scriptIndex: 1 });
const user = input.sides[input.turn];
user.rollout = next(6);
if (next(2)) user.sub[0] = 1 << C.SUBSTATUS_ROLLOUT;
checkRollout.push(input);
}
await compare(page, ["BattleCommand_CheckRollout"], checkRollout);
// checkcharge, checkobedience, doturn, charge | usedmovetext, endmove
const checkCharge: Case[] = [];
for (let i = 0; i < 100; i++) {
const input = make({ script: [COMMAND.checkcharge, COMMAND.checkobedience, COMMAND.doturn, COMMAND.charge, COMMAND.usedmovetext, COMMAND.endmove], scriptIndex: 1 });
input.sides[input.turn].sub[2] = next(256) & ((1 << C.SUBSTATUS_CHARGED) | (1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND) | 1);
checkCharge.push(input);
}
await compare(page, ["BattleCommand_CheckCharge"], checkCharge);
const pursuit: Case[] = [];
for (let i = 0; i < 100; i++)
pursuit.push(make({ curDamage: damage(), playerIsSwitching: next(2), enemyIsSwitching: next(2) }));
await compare(page, ["BattleCommand_Pursuit"], pursuit);
});
test("CheckHit matches Fly, Dig and Lock-On", async ({ page }) => {
const { make, next } = generator(0xf1d);
const moves = [16, 18, 87, 239, 89, 90, 222, 33]; // Gust, Whirlwind, Thunder, Twister, Earthquake, Fissure, Magnitude, Tackle
const cases: Case[] = [];
for (let i = 0; i < 800; i++) {
const input = make({ curDamage: next(1000) });
const user = input.sides[input.turn],
target = input.sides[input.turn ^ 1];
user.move = { ...user.move, anim: moves[next(moves.length)], accuracy: [0, 128, 255][next(3)], effect: next(4) ? 0 : C.EFFECT_JUMP_KICK };
target.sub[2] = [0, 1 << C.SUBSTATUS_FLYING, 1 << C.SUBSTATUS_UNDERGROUND, (1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND)][next(4)];
if (next(2)) target.sub[4] = 1 << C.SUBSTATUS_LOCK_ON;
cases.push(input);
}
await compare(page, ["BattleCommand_CheckHit"], cases);
});