Open Routes 38 to 41, Olivine and Cianwood to the Mineral Badge (#32)
Checks / web (push) Successful in 1m46s
Private ROM checks / private-rom (push) Failing after 39m32s

Enable Route 38 and its Ecruteak gate, Route 39 with the MooMoo farm,
Olivine City and its buildings, the Lighthouse's six floors, Routes 40
and 41, and Cianwood City with its gym, pharmacy, Photo Studio, Mania's
house, the Poke Seer and the Lugia speech house. Olivine's port, the
Battle Tower gate and Whirl Islands stay closed. The story runs from the
rival in Olivine through Jasmine and Amphy, Suicune and Eusine, the
SecretPotion, Chuck's boulders, the STORMBADGE, TM01 and HM02, back to
Amphy's cure, Jasmine, the MINERALBADGE and TM23.

Port GiveShuckle and ReturnShuckie with every return code and Mania's
wrong last-Pokemon speech, PokeSeer with its high-byte-only OT check and
reused caught level, PhotoStudio's no-printer path (Printer Error 2),
and PlaySlowCry. wMooMooBerries joins the saved script bytes.

Port trainer item AI: the 13 healing, status and X items, the
highest-level gate, item use while Mean Look or Wrap prevents
switching, and the original's random and state quirks, checked against
the ROM over 5,632 states. Leaders with items are no longer refused.

Game Corner sprites match the original frame by frame: cursor and reel
positions, Golem's whole-frame flips, frame timing, the frame a removed
sprite is still drawn, and the Golem roll's screen shake. The Burned
Tower rival's garbage trainer header is documented as a boundary: its
seen text dispatches text command $7f into home code before any battle.

The playthrough continues for six stages to Jasmine; its driver can
Surf, fish and travel through floors joined by holes. New tests cover
trainer items, Olivine and Cianwood side content, and reference checks
for trainer items, trainer sight and Game Corner animations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-13 10:09:47 +02:00
1 parent 7c4e8521d4
commit b5c2b19094
29 files changed
+2708 -139

No files matched your search

+230 -9
View File
@@ -197,8 +197,15 @@ export interface BattleTrainer {
* heals, a faint or a withdrawal); damage alone leaves them stale. */
party: PartyMon[];
aiFlags: number;
/** TrainerClassAttributes' AI item/switch word (SWITCH_* in the low byte). */
/** TrainerClassAttributes' AI item/switch word (SWITCH_*, ALWAYS_USE and
* CONTEXT_USE in the low byte). */
switchFlags: number;
/** wEnemyTrainerItem1/2: the trainer's two held items, consumed (set to 0)
* as AI_TryItem uses them. */
items: [number, number];
/** wEnemyTrainerBaseReward: the final reward uses this, and AI_TryItem's
* item-pointer bug can misread it as a third item slot. */
baseReward: number;
reward: number;
winText?: number;
lossText?: number;
@@ -297,7 +304,10 @@ const percent = (n: number) => Math.floor((n * 0xff) / 100);
/** TrainerClassAttributes AI_ITEM_SWITCH bits (constants/trainer_data_constants.asm). */
const SWITCH_OFTEN = 1 << 0,
SWITCH_RARELY = 1 << 1,
SWITCH_SOMETIMES = 1 << 2;
SWITCH_SOMETIMES = 1 << 2,
ALWAYS_USE = 1 << 4,
UNKNOWN_USE = 1 << 5,
CONTEXT_USE = 1 << 6;
/** SwitchOften/SwitchRarely/SwitchSometimes: for each wEnemySwitchMonParam
* tier ($10, $20, $30), a Random threshold and whether a roll below it
* switches (true) or keeps the Pokémon in (false). */
@@ -5642,7 +5652,7 @@ export class Battle {
yield* this.enemyPartyMonEntrance(true);
this.battlePlayerAction = 0;
}
/** ResetEnemyBattleVars. wEnemyItemState is unused while trainer items are refused. */
/** ResetEnemyBattleVars. wEnemyItemState's HUD redraw counter is presentation-only. */
private resetEnemyBattleVars() {
this.player.lastCounterMove = 0;
this.enemy.lastCounterMove = 0;
@@ -5753,21 +5763,25 @@ export class Battle {
}
/** AI_SwitchOrTryItem (engine/battle/ai/items.asm). Returns true when the
* enemy spent its turn. Trainers holding items are refused before battle,
* so AI_TryItem finds none. */
* enemy spent its turn, either switching or using an item. Whenever the
* switch decision falls through to DontSwitch (no switch style, no
* candidate tier, or a failed roll), AI_TryItem gets a turn instead; a
* decided switch that AI_TrySwitch then refuses (too few Pokémon left)
* is a `jp`, not a `call`, so it never reaches DontSwitch either. */
private *aiSwitchOrTryItem(): Gen<boolean> {
const trainer = this.options.trainer;
if (!trainer || this.checkEnemyLockedIn()) return false;
if (this.player.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.enemy.wrapCount) return false;
if (this.player.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.enemy.wrapCount)
return yield* this.aiTryItem();
const style = [SWITCH_OFTEN, SWITCH_RARELY, SWITCH_SOMETIMES].findIndex(
(bit) => trainer.switchFlags & bit,
);
if (style < 0) return false;
if (style < 0) return yield* this.aiTryItem();
const param = this.checkAbleToSwitch(),
tier = param & 0xf0;
if (!tier) return false;
if (!tier) return yield* this.aiTryItem();
const [threshold, below] = SWITCH_CHANCES[style][Math.min(tier >> 4, 3) - 1];
if (this.random() < threshold !== below) return false;
if (this.random() < threshold !== below) return yield* this.aiTryItem();
this.enemySwitchMonIndex = (param & 0x0f) + 1;
// AI_TrySwitch: the withdrawn Pokémon's stale party HP still counts.
if (trainer.party.filter((mon) => mon.hp).length < 2) return false;
@@ -5796,6 +5810,213 @@ export class Battle {
this.battleHasJustStarted = 0;
}
// ------------------------------------------------------- trainer items
/** AI_Items (engine/battle/ai/items.asm): the trainer's items, tried in
* this fixed order against whichever of its two slots AI_TryItem's scan
* currently points at. */
private aiItemHandlers(): { item: number; use: () => Gen<boolean> }[] {
return [
{ item: C.FULL_RESTORE, use: () => this.aiFullRestore() },
{ item: C.MAX_POTION, use: () => this.aiMaxPotion() },
{ item: C.HYPER_POTION, use: () => this.aiPotion(C.HYPER_POTION, 200) },
{ item: C.SUPER_POTION, use: () => this.aiPotion(C.SUPER_POTION, 50) },
{ item: C.POTION, use: () => this.aiPotion(C.POTION, 20) },
{ item: C.X_ACCURACY, use: () => this.aiXSubstatus(C.X_ACCURACY, C.SUBSTATUS_X_ACCURACY) },
{ item: C.FULL_HEAL, use: () => this.aiFullHeal() },
{ item: C.GUARD_SPEC, use: () => this.aiXSubstatus(C.GUARD_SPEC, C.SUBSTATUS_MIST) },
{ item: C.DIRE_HIT, use: () => this.aiXSubstatus(C.DIRE_HIT, C.SUBSTATUS_FOCUS_ENERGY) },
{ item: C.X_ATTACK, use: () => this.aiXStat(C.X_ATTACK, C.ATTACK) },
{ item: C.X_DEFEND, use: () => this.aiXStat(C.X_DEFEND, C.DEFENSE) },
{ item: C.X_SPEED, use: () => this.aiXStat(C.X_SPEED, C.SPEED) },
{ item: C.X_SPECIAL, use: () => this.aiXStat(C.X_SPECIAL, C.SP_ATTACK) },
];
}
/** AI_TryItem: items are refused in the Battle Tower (not reachable in
* this port, so skipped), with none held, or when the Pokémon out is not
* the trainer's highest level. Otherwise AI_Items is scanned in order for
* whichever slot (`slot`, wEnemyTrainerItem1/2 via `de`) currently holds a
* listed item; its handler decides whether to use it.
* BUG: `de` only starts at wEnemyTrainerItem1 once, before the scan, not
* at each entry. Once the second slot matches and its handler declines,
* the first slot is unreachable for the rest of THIS scan (it is tried
* again, fresh, on the trainer's next turn); the pointer can drift once
* more into wEnemyTrainerBaseReward (`slot` 2), which could then be
* misread as a third item if it happened to alias one of AI_Items' ids
* (docs/bugs_and_glitches.md "AI might use its base reward value as an
* item"). No real two-item trainer's base reward matches an item in this
* ROM; further drift into AI flag bytes is not modeled. */
private *aiTryItem(): Gen<boolean> {
const trainer = this.options.trainer!;
if (!trainer.items[0] && !trainer.items[1]) return false;
if (!this.aiIsHighestLevel()) return false;
const slotValue = (index: number) => (index < 2 ? trainer.items[index] : trainer.baseReward);
let slot = 0;
for (const { item, use } of this.aiItemHandlers()) {
let matched = -1;
if (slotValue(slot) === item) matched = slot;
else if (slot < 2 && slotValue(slot + 1) === item) matched = slot + 1;
if (matched < 0) continue;
slot = matched;
if (!(yield* use())) continue;
if (slot < 2) trainer.items[slot] = 0;
else trainer.baseReward = 0;
// "used item": the enemy spent its turn, and several substatuses
// that only a real move would otherwise reset are cleared here too.
this.enemyGoesFirst = 1;
this.enemy.reset(SUB3, C.SUBSTATUS_BIDE);
this.enemy.furyCutterCount = 0;
this.enemy.protectCount = 0;
this.enemy.rageCounter = 0;
this.enemy.reset(SUB4, C.SUBSTATUS_RAGE);
this.enemy.lastCounterMove = 0;
return true;
}
return false;
}
/** AI_TryItem's .IsHighestLevel: the Pokémon out must be at least as
* high level as every other party member (ties count). */
private aiIsHighestLevel(): boolean {
const party = this.options.trainer!.party;
const highest = party.reduce((level, mon) => Math.max(level, mon.level), 0);
return party[this.curOTMon].level >= highest;
}
/** AICheckEnemyHalfHP: true (the asm's carry) when the enemy's HP is
* strictly above half its maximum. */
private aiAboveHalfHP(): boolean {
return this.enemy.mon.hp * 2 > this.enemy.mon.maxHp;
}
/** AICheckEnemyQuarterHP: strictly above a quarter. */
private aiAboveQuarterHP(): boolean {
return this.enemy.mon.hp * 4 > this.enemy.mon.maxHp;
}
/** AI_Items' .HealItem, shared by Full Restore, Max/Hyper/Super Potion
* and Potion: whether this turn should heal, gated by HP and a few
* random rolls that CONTEXT_USE and UNKNOWN_USE change. */
private aiHealItem(): boolean {
const flags = this.options.trainer!.switchFlags;
if (flags & CONTEXT_USE) {
if (this.aiAboveHalfHP()) return false;
if (!this.aiAboveQuarterHP()) return true;
return this.random() < percent(20) - 1;
}
if (this.aiAboveHalfHP()) return false;
if (flags & UNKNOWN_USE) {
if (this.aiAboveQuarterHP()) return false;
return this.random() >= percent(20) - 1;
}
if (!this.aiAboveQuarterHP()) return true;
return this.random() < percent(50) + 1;
}
/** AI_Items' .Status, shared by Full Heal and Full Restore's
* CONTEXT_USE path: whether the enemy's status is worth curing. */
private aiStatus(): boolean {
if (!this.enemy.mon.status) return false;
const flags = this.options.trainer!.switchFlags;
if (flags & CONTEXT_USE) {
if (
this.enemy.has(SUB5, C.SUBSTATUS_TOXIC) &&
this.enemy.toxicCount >= 4 &&
this.random() < percent(50) + 1
)
return true;
return Boolean(this.enemy.mon.status & ((1 << C.FRZ) | C.SLP_MASK));
}
if (flags & ALWAYS_USE) return true;
return this.random() < percent(20) - 1;
}
/** AI_Items' .XItem, shared by the X items, X Accuracy, Guard Spec and
* Dire Hit: more readily used on the enemy's first turn out. */
private aiXItem(): boolean {
const flags = this.options.trainer!.switchFlags;
if (!this.enemy.turnsTaken) {
if (flags & ALWAYS_USE) return true;
if (this.random() < percent(50) + 1) return false;
if (flags & CONTEXT_USE) return true;
return !(this.random() < percent(50) + 1);
}
if (!(flags & ALWAYS_USE)) return false;
return this.random() < percent(20) - 1;
}
/** AI_HealStatus: the party struct's and the enemy's status clear, and
* Toxic stops. Nightmare, confusion and the stat drops from burn or
* paralysis are kept bugs (docs/bugs_and_glitches.md); CalcEnemyStats is
* never called here. */
private aiHealStatus() {
this.options.trainer!.party[this.curOTMon].status = 0;
this.enemy.mon.status = 0;
this.enemy.reset(SUB5, C.SUBSTATUS_TOXIC);
}
/** PrintText_UsedItemOn then AIUpdateHUD: "<ENEMY> used ITEM on mon!",
* the party struct following the enemy's new state, and the HUD. */
private *enemyUsedOn(item: number): Gen {
this.strings[RAM.monOrItemNameBuffer] = this.options.data.itemName(item);
yield* this.say(BATTLE.EnemyUsedOnText);
this.updateEnemyMonInParty();
yield this.hud();
}
/** .FullHeal: EnemyUsedFullHeal if .Status allows it. */
private *aiFullHeal(): Gen<boolean> {
if (!this.aiStatus()) return false;
this.aiHealStatus();
yield* this.enemyUsedOn(C.FULL_HEAL);
return true;
}
/** .FullRestore: .HealItem's HP gate, or (with CONTEXT_USE) .Status's
* verdict instead; AI_HealStatus, then confusion also clears (unlike
* Full Heal, a second kept bug), and the enemy's HP fills. */
private *aiFullRestore(): Gen<boolean> {
const flags = this.options.trainer!.switchFlags;
if (!this.aiHealItem() && (!(flags & CONTEXT_USE) || !this.aiStatus())) return false;
this.aiHealStatus();
this.enemy.reset(SUB3, C.SUBSTATUS_CONFUSED);
this.enemy.confuseCount = 0;
this.enemy.mon.hp = this.enemy.mon.maxHp;
yield* this.enemyUsedOn(C.FULL_RESTORE);
return true;
}
/** .MaxPotion: .HealItem's HP gate, then a full heal without curing
* status. */
private *aiMaxPotion(): Gen<boolean> {
if (!this.aiHealItem()) return false;
this.enemy.mon.hp = this.enemy.mon.maxHp;
yield* this.enemyUsedOn(C.MAX_POTION);
return true;
}
/** .HyperPotion/.SuperPotion/.Potion: .HealItem's HP gate, then
* RestoreHealth (EnemyPotionContinue's own inlined equivalent of the
* same clamped add). */
private *aiPotion(item: number, amount: number): Gen<boolean> {
if (!this.aiHealItem()) return false;
this.enemy.mon.hp = restoreHealth(this.enemy.mon.hp, this.enemy.mon.maxHp, amount);
yield* this.enemyUsedOn(item);
return true;
}
/** EnemyUsedXAccuracy/GuardSpec/DireHit: .XItem's gate, then the
* SubStatus4 flag (set, never toggled off, so a repeat has no further
* effect but still spends the item and the turn). */
private *aiXSubstatus(item: number, bit: number): Gen<boolean> {
if (!this.aiXItem()) return false;
this.enemy.set(SUB4, bit);
yield* this.enemyUsedOn(item);
return true;
}
/** EnemyUsedXAttack/XDefend/XSpeed/XSpecial: .XItem's gate, the message,
* then RaiseStat on the enemy even if the stat cannot rise. Unlike the
* player's own X items (XItemEffect), this never clears wAttackMissed or
* wEffectFailed first, so a stale miss or failed effect left over from
* the last move either side used can make RaiseStat silently no-op; no
* stat-rose or failed text is shown either way. */
private *aiXStat(item: number, stat: number): Gen<boolean> {
if (!this.aiXItem()) return false;
this.strings[RAM.monOrItemNameBuffer] = this.options.data.itemName(item);
yield* this.say(BATTLE.EnemyUsedOnText);
this.raiseStat(stat);
this.updateEnemyMonInParty();
yield this.hud();
return true;
}
/** CheckAbleToSwitch (engine/battle/ai/switch.asm): wEnemySwitchMonParam,
* a chance tier in the high nibble and a party index in the low one.
* CheckTypeMatchup reads BATTLE_VARS_MOVE_TYPE rather than the type its