diff --git a/src/game/battle/battle.ts b/src/game/battle/battle.ts index 73b91c5..7638005 100644 --- a/src/game/battle/battle.ts +++ b/src/game/battle/battle.ts @@ -908,7 +908,37 @@ export class Battle { }; this.player.stats = mon.stats.slice(1); this.applyStatusEffects(this.player); - // No badges exist yet, so BadgeStatBoosts changes nothing. + this.badgeStatBoosts(); + } + /** BadgeStatBoosts: Johto badges raise the battle Pokémon's Attack, + * Defense, Speed and Special Attack by an eighth (to at most 999). The + * PlainBadge and MineralBadge bits are swapped first. The Glacier Badge's + * Special Defense boost tests the register that BoostStat leaves after + * Special Attack, so it depends on that stat (a kept bug). */ + private badgeStatBoosts() { + const badges = this.options.johtoBadges ?? 0; + // ZEPHYR, HIVE, FOG, STORM, GLACIER and RISING stay; PLAIN (bit 2) + // moves to bit 4 and MINERAL (bit 4) to bit 2. + let b = (badges & 0xeb) | ((badges & 0x04) << 2) | ((badges & 0x10) >> 2); + const stats = this.player.mon.stats; + // BoostStat, returning the A register it leaves. + const boost = (index: number) => { + const value = stats[index] + (stats[index] >> 3); + if (value >= C.MAX_STAT_VALUE) { + stats[index] = C.MAX_STAT_VALUE; + return C.MAX_STAT_VALUE & 0xff; + } + stats[index] = value; + return ((value >> 8) - (C.MAX_STAT_VALUE >> 8) - ((value & 0xff) < (C.MAX_STAT_VALUE & 0xff) ? 1 : 0)) & 0xff; + }; + let a = 0; + for (let index = 0; index < 4; index++) { + a = b; + const carry = b & 1; + b >>= 2; + if (carry) a = boost(index); + } + if (a & 1) boost(4); } /** ApplyPrzEffectOnSpeed and ApplyBrnEffectOnAttack for one side. */ private applyStatusEffects(side: Side) { @@ -1599,6 +1629,7 @@ export class Battle { const value = Math.floor((player.stats[stat] * numerator) / denominator); player.mon.stats[stat] = value === 0 ? 1 : Math.min(999, value); } + this.badgeStatBoosts(); this.applyStatusEffects(this.turn === 0 ? this.player : this.enemy); } /** PrintPartyMenuActionText with the Pokémon's nickname. */ @@ -4985,13 +5016,15 @@ export class Battle { } this.failedMessage = 0; } - /** CalcPlayerStats / CalcEnemyStats: stages, then status penalties. */ + /** CalcPlayerStats / CalcEnemyStats: stages, the player's badge + * boosts, then status penalties. */ private calcStats(side: Side) { for (let stat = 0; stat < 5; stat++) { const [numerator, denominator] = this.data.statMultiplier(side.levels[stat]); const value = Math.floor((side.stats[stat] * numerator) / denominator); side.mon.stats[stat] = value === 0 ? 1 : Math.min(999, value); } + if (side === this.player) this.badgeStatBoosts(); this.applyStatusEffects(side); } private statName() { @@ -6019,8 +6052,13 @@ export class Battle { side.mon.hp = mon.hp; side.mon.maxHp = mon.stats[0]; side.mon.level = newLevel; - side.stats = mon.stats.slice(1); - this.calcStats(side); + // A transformed Pokémon keeps its copied stats. + if (!side.has(SUB5, C.SUBSTATUS_TRANSFORMED)) side.stats = mon.stats.slice(1); + // ApplyStatLevelMultiplierOnAllStats, ApplyStatusEffectOnPlayerStats, + // then BadgeStatBoosts. + this.applyStatLevelMultipliers(side); + this.applyStatusEffects(side); + this.badgeStatBoosts(); } // LevelUpHappinessMod: more where it was caught. this.changeHappiness( diff --git a/src/game/battle/constants.ts b/src/game/battle/constants.ts index def3b2e..61ba1d6 100644 --- a/src/game/battle/constants.ts +++ b/src/game/battle/constants.ts @@ -249,6 +249,7 @@ export const C = { PP_MASK: 63, NUM_MOVES: 4, BASE_HAPPINESS: 70, + MAX_STAT_VALUE: 999, START_MONEY: 3000, MAX_MONEY: 999999, MAX_COINS: 9999, diff --git a/tests/reference-badges.spec.ts b/tests/reference-badges.spec.ts new file mode 100644 index 0000000..eeea40f --- /dev/null +++ b/tests/reference-badges.spec.ts @@ -0,0 +1,69 @@ +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("badge stat boosts match the original for every Johto badge byte", async ({ page }) => { + const { at, routine } = loadSymbols(); + const addresses = { + romBank: at("hROMBank"), + badges: at("wJohtoBadges"), + linkMode: at("wLinkMode"), + tower: at("wInBattleTowerBattle"), + stats: at("wBattleMonAttack"), + }; + const boosts = routine("BadgeStatBoosts"); + await openReference(page); + const result = await page.evaluate(async ({ a, boosts }) => { + const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); + const { CrystalAssets } = await import("/src/game/rom/assets.ts"); + const { Battle } = await import("/src/game/battle/battle.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; select the bank; CALL BadgeStatBoosts; sentinel; loop. + // The sentinel lives in audio RAM, away from the battle variables. + const program = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, boosts.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, + 0xcd, boosts.address & 255, boosts.address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe]; + // Around the cap, a range of Special Attack values for the Glacier + // Badge's register bug, and small stats. + const statSets = [ + [1, 2, 3, 4, 5], + [10, 20, 30, 40, 50], + [887, 888, 889, 900, 998], + [999, 999, 999, 999, 999], + ...Array.from({ length: 24 }, (_, i) => [100, 150, 200, 180 + i * 30, 300]), + ]; + const failures: unknown[] = []; + let compared = 0; + for (let badges = 0; badges < 256; badges++) + for (const stats of statSets) { + const battle = new Battle({ + data: assets.pokemon, party: [], pokedex: { seen: new Set(), caught: new Set() }, playerId: 1, + playerName: "CHRIS", timeOfDay: 1, random: () => 0, text: () => ({}) as never, johtoBadges: badges, + }); + battle.player.mon = { stats: [...stats] } as never; + (battle as unknown as { badgeStatBoosts(): void }).badgeStatBoosts(); + write(a.badges, badges); + write(a.linkMode, 0); + write(a.tower, 0); + stats.forEach((value, i) => { write(a.stats + i * 2, value >> 8); write(a.stats + i * 2 + 1, value); }); + write(0xc0f0, 0); + program.forEach((value, i) => write(0xc000 + i, value)); + reference.setProgramCounter(0xc000); + reference.advanceFrames(1); + if (read(0xc0f0) !== 0x42) throw Error("BadgeStatBoosts did not return"); + const original = stats.map((_, i) => (read(a.stats + i * 2) << 8) | read(a.stats + i * 2 + 1)); + const native = battle.player.mon.stats; + if (original.some((value, i) => value !== native[i])) failures.push({ badges, stats, original, native }); + compared++; + } + reference.dispose(); + return { compared, failures: failures.slice(0, 5), count: failures.length }; + }, { a: addresses, boosts }); + expect(result.compared).toBe(256 * 28); + expect(result, JSON.stringify(result)).toMatchObject({ count: 0 }); +});