Port Kurt's balls and the Park Ball's multiplier (#28)

Add the Heavy, Level, Lure, Fast, Friend, Moon and Love Balls with their documented bugs, the Park Ball's multiplier outside the contest, and Friend Ball happiness in the party and the PC. The Heavy Ball is refused for Kadabra and Sunflora, whose original weight read runs into VRAM.

The reference emulator compares 6,153 more catch states per browser against the unmodified PokeBallEffect. Validated with 582 unit tests, 153 browser tests, native/reference builds and reference/vendor/release checks. The Bug-Catching Contest and other special battle types remain on #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 15:38:56 +02:00
1 parent 77704fe848
commit b547ca29ff
8 files changed
+264 -22

No files matched your search

+88 -10
View File
@@ -369,9 +369,10 @@ export function applyTypeMatchups(
/** A native translation of Crystal's battle engine.
* The move effect scripts are the ROM's own; each battle command is a
* native translation. Unsupported AI, switching, contest and item paths are refused. */
/** PokeBallEffect's wFinalCatchRate for Poké, Great and Ultra Balls, in
* the original 8- and 16-bit registers. Returns undefined when the
* formula would divide by zero (e.g. maximum HP of 342 or 683). */
/** PokeBallEffect's wFinalCatchRate in the original 8- and 16-bit
* registers. `rate` already has the battle-dependent Kurt's ball multipliers
* applied (Battle.kurtBallRate). Returns undefined when the formula would
* divide by zero (e.g. maximum HP of 342 or 683). */
export function catchRate(input: {
rate: number;
ball: number;
@@ -384,7 +385,10 @@ export function catchRate(input: {
let rate = input.rate;
// BallMultiplierFunctionTable
if (input.ball === C.ULTRA_BALL) rate = Math.min(0xff, rate << 1);
else if (input.ball === C.GREAT_BALL) rate = Math.min(0xff, rate + (rate >> 1));
else if (input.ball === C.GREAT_BALL || input.ball === C.PARK_BALL)
rate = Math.min(0xff, rate + (rate >> 1));
// The Level Ball skips the HP, status and held-item terms.
if (input.ball === C.LEVEL_BALL) return rate;
// A maximum HP above 341 overflows the HP term (a kept bug).
let hp = (input.hp << 1) & 0xffff,
max = (input.maxHp * 3) & 0xffff;
@@ -1545,7 +1549,10 @@ export class Battle {
yield* this.say(BATTLE.BallBoxFullText);
return false;
}
if (![C.MASTER_BALL, C.ULTRA_BALL, C.GREAT_BALL, C.POKE_BALL].includes(item as never))
// The Park Ball outside the Bug-Catching Contest only skips the throw
// animation's setup (a graphical bug).
if (![C.MASTER_BALL, C.ULTRA_BALL, C.GREAT_BALL, C.POKE_BALL, C.HEAVY_BALL, C.LEVEL_BALL,
C.LURE_BALL, C.FAST_BALL, C.FRIEND_BALL, C.MOON_BALL, C.LOVE_BALL, C.PARK_BALL].includes(item as never))
throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`);
this.wildMon = 0;
yield* this.say(BATTLE.ItemUsedText);
@@ -1606,7 +1613,7 @@ export class Battle {
if (!this.options.caughtData) throw new UnsupportedBattle("Missing caught data.");
const name = data.speciesName(species);
if (this.options.party.length >= PARTY_LENGTH) {
yield* this.sendToPC(species, level, name);
yield* this.sendToPC(species, level, name, item);
this.tossItem("balls", item, slot);
return true;
}
@@ -1623,7 +1630,7 @@ export class Battle {
statExp: [0, 0, 0, 0, 0],
dvs: enemy.dvs,
pp: [...this.wildPP] as PartyMon["pp"],
happiness: C.BASE_HAPPINESS,
happiness: item === C.FRIEND_BALL ? C.FRIEND_BALL_HAPPINESS : C.BASE_HAPPINESS,
pokerus: 0,
caughtData: this.options.caughtData(level),
level,
@@ -1642,7 +1649,7 @@ export class Battle {
if (!caught) {
const held = this.data.heldEffect(this.player.mon?.item ?? 0);
const chance = catchRate({
rate: this.enemyBase.catchRate,
rate: this.kurtBallRate(item, this.enemyBase.catchRate),
ball: item,
hp: this.enemy.mon.hp,
maxHp: this.enemy.mon.maxHp,
@@ -1657,6 +1664,77 @@ export class Battle {
return { caught, wobbles: this.ballWobbles(caught) };
}
/** BallMultiplierFunctionTable's Kurt's balls, which depend on the battle.
* Their documented bugs are kept: the Heavy Ball reads three species'
* weights from the wrong bank, the Fast Ball checks only FleeMons' first
* three bytes, the Moon Ball compares the wrong byte with BURN_HEAL and
* the Love Ball wants the same gender. */
private kurtBallRate(item: number, rate: number): number {
const r = this.reader,
data = this.options.data,
// Each doubling saturates at 255, as the original's carry checks do.
double = (times: number) => {
for (let i = 0; i < times; i++) rate = Math.min(0xff, rate << 1);
return rate;
};
switch (item) {
case C.HEAVY_BALL: {
// wEnemyMonSpecies, which Transform changes.
const species = this.enemy.mon.species;
const bank = r.byte(BATTLE["HeavyBall_GetDexEntryBank.PokedexEntryBanks"] + ((species >> 6) & 3));
let pointer = r.word(BATTLE.PokedexDataPointerTable + (species - 1) * 2);
// Kadabra's and Sunflora's scans run off the wrong bank into VRAM.
const at = (address: number) => {
if (address < 0x4000 || address > 0x7fff)
throw new UnsupportedBattle("The Heavy Ball reads this Pokémon's weight from VRAM.");
return r.byte(r.address(bank, address));
};
while (at(pointer++) !== 0x50);
let hl = (at(pointer + 2) | (at(pointer + 3) << 8)) >> 1;
let bc = hl >> 4;
hl = (hl - bc) & 0xffff;
bc >>= 1;
hl = (hl - bc) & 0xffff;
const weight = hl >> 8;
if (weight < 4) return rate >= 20 ? rate - 20 : 1;
let entry = BATTLE["HeavyBallMultiplier.WeightsTable"];
while (weight >= r.byte(entry)) entry += 2;
return Math.min(0xff, rate + r.byte(entry + 1));
}
case C.LEVEL_BALL: {
let player = this.player.mon.level;
const enemy = this.enemy.mon.level;
for (let i = 0; i < 3; i++) {
if (enemy >= player) break;
double(1);
if (rate === 0xff) break;
player >>= 1;
}
return rate;
}
case C.LURE_BALL:
if (this.battleType !== C.BATTLETYPE_FISH) return rate;
return rate * 2 > 0xff ? 0xff : Math.min(0xff, rate * 3);
case C.FAST_BALL:
// The loop counts bytes rather than lists, stopping after three.
if (r.slice(BATTLE.FleeMons, 3).includes(this.enemySpecies)) double(2);
return rate;
case C.MOON_BALL: {
const p = r.near(SYMBOLS.evosAttacks + (this.enemySpecies - 1) * 2, r.bank(SYMBOLS.evosAttacks));
if (r.byte(p) === C.EVOLVE_ITEM && r.byte(p + 3) === C.MOON_STONE_RED) double(2);
return rate;
}
case C.LOVE_BALL: {
if (this.enemySpecies !== this.partyMon.species) return rate;
const player = data.gender(this.partyMon.species, this.partyMon.dvs),
wild = data.gender(this.enemySpecies, this.enemy.mon.dvs);
if (player && wild && player === wild) double(3);
return rate;
}
}
return rate;
}
private ballWobbles(caught: boolean): number {
// BattleAnim_ThrowPokeBall's .Shake loop runs GetPokeBallWobble until
// the ball clicks or the Pokémon breaks free.
@@ -1694,7 +1772,7 @@ export class Battle {
* first in the current box (from wEnemyMon, with restored PP), then
* SetBoxMonCaughtData. A box filled by it sets BATTLERESULT_BOX_FULL for
* Bill's call; the nickname and BallSentToPCText follow. */
private *sendToPC(species: number, level: number, name: string): Gen {
private *sendToPC(species: number, level: number, name: string, item: number): Gen {
const data = this.options.data,
storage = this.options.storage!,
box = currentBox(storage);
@@ -1709,7 +1787,7 @@ export class Battle {
statExp: [0, 0, 0, 0, 0],
dvs: this.enemy.mon.dvs,
pp: [...this.wildPP] as BoxMon["pp"],
happiness: C.BASE_HAPPINESS,
happiness: item === C.FRIEND_BALL ? C.FRIEND_BALL_HAPPINESS : C.BASE_HAPPINESS,
pokerus: 0,
caughtData: this.options.caughtData!(level),
level,
+11
View File
@@ -188,6 +188,17 @@ export const C = {
ULTRA_BALL: 2,
GREAT_BALL: 4,
POKE_BALL: 5,
HEAVY_BALL: 0x9d,
LEVEL_BALL: 0x9f,
LURE_BALL: 0xa0,
FAST_BALL: 0xa1,
FRIEND_BALL: 0xa4,
MOON_BALL: 0xa5,
LOVE_BALL: 0xa6,
PARK_BALL: 0xb1,
/** MoonBallMultiplier compares with this, which is BURN_HEAL. */
MOON_STONE_RED: 0x0a,
FRIEND_BALL_HAPPINESS: 200,
LUCKY_PUNCH: 30,
METAL_POWDER: 35,
BERSERK_GENE: 0x98,
+4
View File
@@ -589,6 +589,10 @@ export const BATTLE = {
RainDanceMoves: 0x390e7,
SunnyDayMoves: 0x39134,
AI_Smart_EffectHandlers: 0x386f2,
PokedexDataPointerTable: 0x44378,
"HeavyBall_GetDexEntryBank.PokedexEntryBanks": 0xec4c,
"HeavyBallMultiplier.WeightsTable": 0xecc4,
FleeMons: 0x3c59a,
DownpourText: 0x817a3,
SunGotBrightText: 0x817b8,