Port the Johto badge stat boosts (#31)
BadgeStatBoosts runs where the original calls it: when a Pokémon is sent out, after an in-battle level-up, and in CalcPlayerStats. It keeps the PlainBadge/MineralBadge swap and the Glacier Badge's Special Defense register bug. A transformed Pokémon that levels up now keeps its copied stats. The reference emulator compares all 256 badge bytes over 28 stat sets per browser against the unmodified routine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8fae1f0c0f
commit
ec3d1cd5c4
3 files changed
+112
-4
No files matched your search
@@ -908,7 +908,37 @@ export class Battle {
|
|||||||
};
|
};
|
||||||
this.player.stats = mon.stats.slice(1);
|
this.player.stats = mon.stats.slice(1);
|
||||||
this.applyStatusEffects(this.player);
|
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. */
|
/** ApplyPrzEffectOnSpeed and ApplyBrnEffectOnAttack for one side. */
|
||||||
private applyStatusEffects(side: Side) {
|
private applyStatusEffects(side: Side) {
|
||||||
@@ -1599,6 +1629,7 @@ export class Battle {
|
|||||||
const value = Math.floor((player.stats[stat] * numerator) / denominator);
|
const value = Math.floor((player.stats[stat] * numerator) / denominator);
|
||||||
player.mon.stats[stat] = value === 0 ? 1 : Math.min(999, value);
|
player.mon.stats[stat] = value === 0 ? 1 : Math.min(999, value);
|
||||||
}
|
}
|
||||||
|
this.badgeStatBoosts();
|
||||||
this.applyStatusEffects(this.turn === 0 ? this.player : this.enemy);
|
this.applyStatusEffects(this.turn === 0 ? this.player : this.enemy);
|
||||||
}
|
}
|
||||||
/** PrintPartyMenuActionText with the Pokémon's nickname. */
|
/** PrintPartyMenuActionText with the Pokémon's nickname. */
|
||||||
@@ -4985,13 +5016,15 @@ export class Battle {
|
|||||||
}
|
}
|
||||||
this.failedMessage = 0;
|
this.failedMessage = 0;
|
||||||
}
|
}
|
||||||
/** CalcPlayerStats / CalcEnemyStats: stages, then status penalties. */
|
/** CalcPlayerStats / CalcEnemyStats: stages, the player's badge
|
||||||
|
* boosts, then status penalties. */
|
||||||
private calcStats(side: Side) {
|
private calcStats(side: Side) {
|
||||||
for (let stat = 0; stat < 5; stat++) {
|
for (let stat = 0; stat < 5; stat++) {
|
||||||
const [numerator, denominator] = this.data.statMultiplier(side.levels[stat]);
|
const [numerator, denominator] = this.data.statMultiplier(side.levels[stat]);
|
||||||
const value = Math.floor((side.stats[stat] * numerator) / denominator);
|
const value = Math.floor((side.stats[stat] * numerator) / denominator);
|
||||||
side.mon.stats[stat] = value === 0 ? 1 : Math.min(999, value);
|
side.mon.stats[stat] = value === 0 ? 1 : Math.min(999, value);
|
||||||
}
|
}
|
||||||
|
if (side === this.player) this.badgeStatBoosts();
|
||||||
this.applyStatusEffects(side);
|
this.applyStatusEffects(side);
|
||||||
}
|
}
|
||||||
private statName() {
|
private statName() {
|
||||||
@@ -6019,8 +6052,13 @@ export class Battle {
|
|||||||
side.mon.hp = mon.hp;
|
side.mon.hp = mon.hp;
|
||||||
side.mon.maxHp = mon.stats[0];
|
side.mon.maxHp = mon.stats[0];
|
||||||
side.mon.level = newLevel;
|
side.mon.level = newLevel;
|
||||||
side.stats = mon.stats.slice(1);
|
// A transformed Pokémon keeps its copied stats.
|
||||||
this.calcStats(side);
|
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.
|
// LevelUpHappinessMod: more where it was caught.
|
||||||
this.changeHappiness(
|
this.changeHappiness(
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ export const C = {
|
|||||||
PP_MASK: 63,
|
PP_MASK: 63,
|
||||||
NUM_MOVES: 4,
|
NUM_MOVES: 4,
|
||||||
BASE_HAPPINESS: 70,
|
BASE_HAPPINESS: 70,
|
||||||
|
MAX_STAT_VALUE: 999,
|
||||||
START_MONEY: 3000,
|
START_MONEY: 3000,
|
||||||
MAX_MONEY: 999999,
|
MAX_MONEY: 999999,
|
||||||
MAX_COINS: 9999,
|
MAX_COINS: 9999,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
Reference in new issue
Block a user