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
+12
View File
@@ -188,7 +188,19 @@ export const C = {
ULTRA_BALL: 2,
GREAT_BALL: 4,
POKE_BALL: 5,
FULL_RESTORE: 0x0e,
MAX_POTION: 0x0f,
HYPER_POTION: 0x10,
SUPER_POTION: 0x11,
POTION: 0x12,
X_ACCURACY: 0x21,
FULL_HEAL: 0x26,
GUARD_SPEC: 0x29,
DIRE_HIT: 0x2c,
X_ATTACK: 0x31,
X_DEFEND: 0x33,
X_SPEED: 0x34,
X_SPECIAL: 0x35,
AWAKENING: 0x0c,
HEAVY_BALL: 0x9d,
LEVEL_BALL: 0x9f,
+8 -5
View File
@@ -22,19 +22,22 @@ export function drawTileScreen(
// background's own color index to clip against; a screen with none
// pays nothing beyond the allocation.
const indices = new Uint8Array(width * height);
for (let row = 0; row < SCREEN_HEIGHT; row++)
const scrollY = screen.scroll?.y ?? 0;
for (let row = scrollY < 0 ? -1 : 0; row < SCREEN_HEIGHT + (scrollY > 0 ? 1 : 0); row++)
for (let col = 0; col < SCREEN_WIDTH; col++) {
const cell = row * SCREEN_WIDTH + col;
if (screen.map[cell] < 0) continue;
const inside = row >= 0 && row < SCREEN_HEIGHT;
const tile = inside ? screen.map[cell] : screen.scroll!.fill;
if (tile < 0) continue;
drawTile(
image.data,
width,
height,
screen.tiles,
screen.map[cell],
tile,
col * 8,
row * 8,
screen.palettes[screen.attrs[cell]] ?? screen.palettes[0],
row * 8 - scrollY,
(inside && screen.palettes[screen.attrs[cell]]) || screen.palettes[0],
{ indices },
);
}
+61
View File
@@ -829,6 +829,7 @@ export const BATTLE = {
DisabledNoMoreText: 0x80d39,
DoesntAffectText: 0x81071,
EnemyText: 0x1295,
EnemyUsedOnText: 0x3858c,
EnemyWithdrewText: 0x384d0,
ExpPointsText: 0x3f131,
FastAsleepText: 0x80ce7,
@@ -1063,6 +1064,30 @@ export const OPTIONS = {
/** Johto's scripts, routines and tables from Route 32 on, keyed by their
* PRET labels for `npm run check:reference`. */
export const JOHTO = {
"GiveShuckle": 0x7305,
"ReturnShuckie": 0x737e,
"PokeSeer": 0x4f0bc,
"PhotoStudio": 0x16dc7,
"PlaySlowCry": 0xfb841,
"SpecialShuckleOT": 0x7370,
"SpecialShuckleNickname": 0x7376,
"SeerSeeAllText": 0x4f28c,
"SeerCantTellAThingText": 0x4f291,
"SeerNameLocationText": 0x4f296,
"SeerTimeLevelText": 0x4f29b,
"SeerTradeText": 0x4f2a0,
"SeerNoLocationText": 0x4f2a5,
"SeerEggText": 0x4f2aa,
"SeerDoNothingText": 0x4f2af,
"SeerAdviceTexts": 0x4f2d6,
"GetCaughtTime.times": 0x4f1e6,
"UnknownCaughtData.unknown": 0x4f202,
"PhotoStudio.WhichMonPhotoText": 0x16e04,
"PhotoStudio.HoldStillText": 0x16e09,
"PhotoStudio.NoPhotoText": 0x16e13,
"PhotoStudio.EggPhotoText": 0x16e18,
"GBPrinterString_PrinterError2": 0x1dc2e2,
"String_PressBToCancel": 0x847f5,
UnlockedUnownLetterSets: 0x3eba1,
UnownPicPointers: 0x124000,
"InitUnownPuzzlePiecePositions.PuzzlePieceInitialPositions": 0xe126d,
@@ -1235,6 +1260,7 @@ export const JOHTO = {
* writemem, loadmem and readvar), keyed by their PRET labels; the native
* save keeps them by label in `memory`. */
export const SAVED_WRAM = {
wMooMooBerries: 0xd962,
wFarfetchdPosition: 0xd964,
wKurtApricornQuantity: 0xdca4,
wUndergroundSwitchPositions: 0xd963,
@@ -1243,6 +1269,11 @@ export type SavedByte = keyof typeof SAVED_WRAM;
/** Work RAM the native port reads by address but does not save, keyed by
* PRET labels. */
export const WRAM = {
"wSeerNickname": 0xd003,
"wSeerCaughtLocation": 0xd00e,
"wSeerTimeOfDay": 0xd01f,
"wSeerCaughtLevelString": 0xd036,
"wSeerOT": 0xd02a,
wTilemap: 0xc4a0,
wBugContestWinnerName: 0xd016,
wBugContestFirstPlaceScore: 0xd004,
@@ -1264,6 +1295,36 @@ export const LAST_MAP_GROUP_SIZE = 11;
* maps remain unavailable; the label only distinguishes interiors that share
* a ROM landmark name. */
export const NATIVE_MAPS: Record<string, string> = {
"1:1": "Olivine Pokecenter 1F",
"1:2": "Olivine Gym",
"1:3": "Olivine Tims House",
"1:4": "Olivine House Beta",
"1:5": "Olivine Punishment Speech House",
"1:6": "Olivine Good Rod House",
"1:7": "Olivine Cafe",
"1:8": "Olivine Mart",
"1:9": "Route 38Ecruteak Gate",
"1:10": "Route 39Barn",
"1:11": "Route 39Farmhouse",
"1:12": "Route 38",
"1:13": "Route 39",
"1:14": "Olivine City",
"3:42": "Olivine Lighthouse 1F",
"3:43": "Olivine Lighthouse 2F",
"3:44": "Olivine Lighthouse 3F",
"3:45": "Olivine Lighthouse 4F",
"3:46": "Olivine Lighthouse 5F",
"3:47": "Olivine Lighthouse 6F",
"22:1": "Route 40",
"22:2": "Route 41",
"22:3": "Cianwood City",
"22:4": "Manias House",
"22:5": "Cianwood Gym",
"22:6": "Cianwood Pokecenter 1F",
"22:7": "Cianwood Pharmacy",
"22:8": "Cianwood Photo Studio",
"22:9": "Cianwood Lugia Speech House",
"22:10": "Poke Seers House",
"24:3": "Route 29",
"24:13": "Route 29 gate",
"24:4": "New Bark Town",
+9 -5
View File
@@ -15,7 +15,7 @@ import {
type ScreenSprite,
type TileScreen,
} from "./screen";
import { checkCoinsAndCoinCase, signed8 } from "./slot-machine";
import { checkCoinsAndCoinCase } from "./slot-machine";
/** CARDFLIP_DECK_SIZE (constants/misc_constants.asm): 4 species x 6 levels. */
const DECK_SIZE = 24;
@@ -208,8 +208,12 @@ function drawMessage(screen: TileScreen, pages: string[]) {
* the slot machine's own sprite frames. The pointer is resolved and read
* directly, rather than this port naming which named table each of the 48
* cells uses. CGB draws these every tick regardless of hVBlankCounter
* (DMG's own "and $4" blink only applies without hCGB set). */
function cursorSprites(reader: RomReader, cursorX: number, cursorY: number): ScreenSprite[] {
* (DMG's own "and $4" blink only applies without hCGB set). The anchor and
* each style entry are raw hardware OAM bytes (CardFlip_CopyOAM just adds
* them with 8-bit wraparound into wShadowOAM); ScreenSprite.x/y are plain
* screen pixels, 8 (x) and 16 (y) below that, as unown-puzzle.ts's own OAM
* reader converts. */
export function cursorSprites(reader: RomReader, cursorX: number, cursorY: number): ScreenSprite[] {
const table = JOHTO["CardFlip_UpdateCursorOAM.OAMData"];
const cell = cursorY * 6 + cursorX;
const entry = table + cell * 4;
@@ -221,8 +225,8 @@ function cursorSprites(reader: RomReader, cursorX: number, cursorY: number): Scr
const p = style + 1 + i * 4;
const attr = reader.byte(p + 3);
return {
x: anchorX + signed8(reader.byte(p + 1)),
y: anchorY + signed8(reader.byte(p)),
x: ((anchorX + reader.byte(p + 1)) & 0xff) - 8,
y: ((anchorY + reader.byte(p)) & 0xff) - 16,
tile: OBJ_BASE + reader.byte(p + 2),
palette: attr & 7, // OAM_PALETTE (CGB)
flipX: Boolean(attr & 0x20), // OAM_XFLIP
+103
View File
@@ -0,0 +1,103 @@
import { JOHTO, WRAM } from "../rom/profile";
import { generatePartyMon } from "../party/pokemon";
import { readString } from "./text";
import { landmarkName } from "./interpreter";
import type { Gen, SpecialContext } from "./special-context";
const SHUCKLE = 213, BERRY = 0xad, MANIA_ID = 518;
// ENGINE_GOT_SHUCKIE_TODAY (constants/engine_flags.asm).
const GOT_SHUCKIE_TODAY = 85;
/** GiveShuckle: a party-only gift; no nickname prompt or box fallback. */
export function giveShuckle(c: SpecialContext): number {
if (c.state.party.length >= 6) return 0;
const mon = generatePartyMon(c.data, {
species: SHUCKLE, level: 15, item: BERRY,
otId: MANIA_ID, ot: readString(c.reader, JOHTO.SpecialShuckleOT, c.text()),
dvs: (c.random() << 8) | c.random(), caughtData: 0x7e,
});
mon.nickname = readString(c.reader, JOHTO.SpecialShuckleNickname, c.text());
c.state.party.push(mon);
c.state.pokedex.seen.add(SHUCKLE);
c.state.pokedex.caught.add(SHUCKLE);
c.state.flags.add(GOT_SHUCKIE_TODAY);
return 1;
}
/** ReturnShuckie, including CheckCurPartyMonFainted's check of the OTHER
* party members and Mania's original last-Pokémon dialogue bug. */
export function* returnShuckie(c: SpecialContext): Gen<number> {
const slot = yield* c.selectMon();
if (slot < 0) return 1;
const mon = c.state.party[slot];
if (mon.egg || mon.species !== SHUCKLE || mon.otId !== MANIA_ID ||
mon.ot !== readString(c.reader, JOHTO.SpecialShuckleOT, c.text())) return 0;
if (!c.state.party.some((other, i) => i !== slot && other.hp > 0)) return 4;
if (mon.happiness >= 150) return 3;
c.state.party.splice(slot, 1);
return 2;
}
/** ReadCaughtData's action and level, including its missing low-ID-byte
* comparison. A zero caught level leaves the scratch level unchanged. */
export function seerReading(caught: number, otId: number, playerId: number, previousLevel: number) {
const level = (caught >> 8) & 63, location = caught & 0x7f;
const caughtLevel = level ? (level === 63 ? 5 : level) : previousLevel;
const action = !caught ? 2 : location === 0x7e ? 3 : location === 0x7f ? 4 :
(otId >> 8) === (playerId >> 8) ? 0 : 1;
return { action, caughtLevel, level, location, time: caught >> 14 };
}
export function* pokeSeer(c: SpecialContext, scratch: { caughtLevel: number }): Gen {
const say = function* (address: number): Gen { c.write(address); yield* c.flush(); };
yield* say(JOHTO.SeerSeeAllText);
const slot = yield* c.selectMon();
if (slot < 0) return yield* say(JOHTO.SeerDoNothingText);
const mon = c.state.party[slot];
if (mon.egg) return yield* say(JOHTO.SeerEggText);
const reading = seerReading(mon.caughtData, mon.otId, c.player().id, scratch.caughtLevel);
if (reading.action === 2) return yield* say(JOHTO.SeerCantTellAThingText);
scratch.caughtLevel = reading.caughtLevel;
const unknown = readString(c.reader, JOHTO["UnknownCaughtData.unknown"], c.text());
c.strings[WRAM.wSeerNickname] = mon.nickname;
c.strings[WRAM.wSeerOT] = mon.ot;
c.strings[WRAM.wSeerCaughtLevelString] = reading.level ? String(reading.caughtLevel) : "???";
c.strings[WRAM.wSeerCaughtLocation] = reading.location < 0x7e && reading.location
? landmarkName(c.reader, reading.location, c.text()) : unknown;
let time = JOHTO["GetCaughtTime.times"];
for (let i = 1; i < reading.time; i++) while (c.reader.byte(time++) !== 0x50);
c.strings[WRAM.wSeerTimeOfDay] = reading.time ? readString(c.reader, time, c.text()) : unknown;
if (reading.action === 3) return yield* say(JOHTO.SeerCantTellAThingText);
if (reading.action === 4) yield* say(JOHTO.SeerNoLocationText);
else {
yield* say(reading.action === 0 ? JOHTO.SeerNameLocationText : JOHTO.SeerTradeText);
yield* say(JOHTO.SeerTimeLevelText);
}
let row = JOHTO.SeerAdviceTexts;
while (((mon.level - reading.caughtLevel) & 255) > c.reader.byte(row)) row += 3;
yield* say(c.reader.near(row + 1, c.reader.bank(row)));
}
/** PhotoStudio's selection and absent-printer cancellation path. The native
* game has no printer transport; never report a successful print. With no
* printer, PrintPartymon's handshake reads $ff, CheckPrinterStatus reports
* PRINTER_ERROR_2 and SendScreenToPrinter retries until B sets hPrinter.
* The page shows that status box's text; the Pokémon's print page behind
* it and the box's alternation with "CHECKING LINK" on each retry are not
* drawn. */
export function* photoStudio(c: SpecialContext): Gen {
c.write(JOHTO["PhotoStudio.WhichMonPhotoText"]);
const slot = yield* c.selectMon();
if (slot >= 0 && c.state.party[slot].egg) c.write(JOHTO["PhotoStudio.EggPhotoText"]);
else {
if (slot >= 0) {
c.write(JOHTO["PhotoStudio.HoldStillText"]);
yield* c.flush();
const status = readString(c.reader, JOHTO.GBPrinterString_PrinterError2, c.text(), 64);
const cancel = readString(c.reader, JOHTO.String_PressBToCancel, c.text());
yield { kind: "text", pages: [`${status}\n\n${cancel}`] };
}
c.write(JOHTO["PhotoStudio.NoPhotoText"]);
}
yield* c.flush();
}
+16
View File
@@ -4,6 +4,7 @@ import {
type Decorations,
} from "../world/decorations";
import { RomReader } from "../rom/reader";
import { giveShuckle, returnShuckie, pokeSeer, photoStudio } from "./cianwood";
import {
EMPTY_ROUTINES,
ENGINE_FLAGS,
@@ -673,6 +674,8 @@ export type ScriptEffect =
/** What the world supplies about the present moment. */
export interface ScriptHost {
/** Volatile Poké Seer scratch byte; not cartridge save data. */
seerMemory?: { caughtLevel: number };
mapId: string;
lastTalked: number;
/** A map's scene variable owner, or undefined when it has no scenes. */
@@ -3375,6 +3378,19 @@ export class ScriptRun {
case JOHTO.NameRater:
yield* nameRater(this.specialContext(pages, take));
return value;
case JOHTO.GiveShuckle:
return giveShuckle(this.specialContext(pages, take));
case JOHTO.ReturnShuckie:
return yield* returnShuckie(this.specialContext(pages, take));
case JOHTO.PokeSeer:
yield* pokeSeer(this.specialContext(pages, take), this.host.seerMemory ?? { caughtLevel: 0 });
return value;
case JOHTO.PhotoStudio:
yield* photoStudio(this.specialContext(pages, take));
return value;
case JOHTO.PlaySlowCry:
// Cry pitch/duration is audio, like the ordinary cry opcode.
return value;
case JOHTO.GameCornerPrizeMonCheckDex:
if (this.state.pokedex.caught.has(value)) return value;
this.state.pokedex.seen.add(value);
+4
View File
@@ -53,6 +53,10 @@ export interface TileScreen {
label: string;
/** Drawn over the overworld, like a menu box, rather than replacing it. */
overlay?: boolean;
/** hSCY as a signed pixel offset: the background (not the sprites)
* moves up by `y`, and rows scrolled in from beyond the tilemap show
* `fill` (what the screen left in the rest of the 32-row BG map). */
scroll?: { y: number; fill: number };
}
/** A GBC color (RGB555, little-endian word) as RGB. */
+130 -90
View File
@@ -121,6 +121,8 @@ export interface SlotsSession {
golem?: GolemState;
chansey?: ChanseyState;
egg?: EggState;
/** hSCY: the background shake while a Golem rolls (0 or unset at rest). */
scy?: number;
}
export function readReelTilemap(reader: RomReader, address: number): number[] {
@@ -288,28 +290,27 @@ const CREATURE_BASE_TILE = 0x100;
const GOLEM_VTILE = [0x00, 0x08] as const; // SLOTS_GOLEM_1/_2 (walk frames)
const CHANSEY_VTILE = 0x10; // every SLOTS_CHANSEY_* OAMSET shares this base
const EGG_VTILE = 0x3a;
/** Frameset_SlotsGolem/_SlotsChansey/_SlotsChansey2 (data/sprite_anims/
* framesets.asm): each OAM frame shown for 7 ticks. Golem's own 3rd/4th
* frames (the same art, Y/X-flipped) are left out, cycling between its
* first two instead: flipping a multi-sprite frame as a whole is outside
* this port's per-sprite flip model, and the visual difference is minor
* for a 2-frame alternation. Chansey's two framesets have no flips and
* are reproduced in full. */
const GOLEM_FRAMES = [0, 1] as const;
/** GetSpriteAnimFrame holds each duration-7 frame for its initial draw
* plus seven decrements. Golem's third/fourth frames flip the whole OAM. */
const GOLEM_FRAMES = [0, 1, 0, 1] as const;
const CHANSEY_FRAMES_1 = [0, 1, 0, 2] as const;
const CHANSEY_FRAMES_2 = [0, 3, 4, 3, 0] as const;
const CREATURE_FRAME_TICKS = 7;
/** Slots_AnimateGolem's own state (SPRITEANIMSTRUCT_VAR1/VAR3/XOFFSET;
* VAR2's hSCY screen shake while rolling is not presented). */
/** Slots_AnimateGolem's own state (SPRITEANIMSTRUCT_VAR1/VAR2/VAR3/
* XOFFSET; VAR2 is the hSCY shake while rolling). */
interface GolemState {
phase: "fall" | "roll";
phase: "init" | "fall" | "roll";
var1: number;
var2?: number;
var3: number;
xOffset: number;
yOffset: number;
frame: number;
frameTick: number;
/** DeinitializeSprite ran this tick: UpdateAnimFrame still draws the
* struct once, and DoNextFrameForAllSprites skips it from the next. */
removed?: boolean;
}
/** Slots_AnimateChansey's own state, plus SpriteAnimFunc_SlotsChansey's
* extra check that swaps it to the second frameset once wSlotsDelay
@@ -318,32 +319,35 @@ interface GolemState {
* WaitChansey, so the whole walk-free loop (wait, lay, spawn) repeats for
* as long as the egg keeps rolling to the wrong result. */
interface ChanseyState {
phase: "walk" | "one" | "retain" | "two";
phase: "walk" | "one" | "two";
xCoord: number;
var1: number;
frameset: 1 | 2;
frame: number;
frameTick: number;
removed?: boolean;
}
/** SpriteAnimFunc_SlotsChanseyEgg's own countdown/bounce state. */
interface EggState {
counter: number;
xCoord: number;
yOffset: number;
removed?: boolean;
}
function advanceFrame(frames: readonly number[], frame: number, tick: number): [number, number] {
if (++tick < CREATURE_FRAME_TICKS) return [frame, tick];
return [(frame + 1) % frames.length, 0];
function advanceFrame(frames: readonly number[], frame: number, tick: number, loop = true): [number, number] {
if (tick > 0) return [frame, tick - 1];
const next = frame + 1;
return [next < frames.length ? next : loop ? 0 : frames.length - 1, CREATURE_FRAME_TICKS];
}
/** ReelAction_InitGolem's sprite init (depixel 12, 13). */
function initGolem(session: SlotsSession, count: number) {
export function initGolem(session: SlotsSession, count: number) {
session.golem = {
phase: "fall",
var1: 0x30,
var3: count - 1,
phase: "init",
var1: 0,
var3: count,
xOffset: 0,
yOffset: 0,
frame: 0,
frame: -1,
frameTick: 0,
};
}
@@ -351,13 +355,33 @@ function initGolem(session: SlotsSession, count: number) {
* DoNextFrameForFirst16Sprites runs after Slots_SpinReels): falls, then
* rolls across until var3 (the probe's count) is exhausted, at which
* point wSlotsDelay=2 tells ReelAction_WaitGolem to stop reel 3 for real. */
function stepGolem(session: SlotsSession, reader: RomReader) {
export function stepGolem(session: SlotsSession, reader: RomReader) {
const g = session.golem;
if (!g) return;
if (g.removed) {
session.golem = undefined;
return;
}
animateGolem(session, g, reader);
// UpdateAnimFrame, after the animation function.
[g.frame, g.frameTick] = advanceFrame(GOLEM_FRAMES, g.frame, g.frameTick);
}
function animateGolem(session: SlotsSession, g: GolemState, reader: RomReader) {
if (g.phase === "init") {
if (!g.var3) {
session.delay = 2;
g.removed = true;
return;
}
g.var3--;
g.phase = "fall";
g.var1 = 0x30;
g.xOffset = 0;
}
if (g.phase === "fall") {
if (g.var1 < 0x20) {
g.phase = "roll";
g.var2 = 2;
session.delay = 1; // .play_sound: one golem has landed; hop reel 3.
return;
}
@@ -370,34 +394,41 @@ function stepGolem(session: SlotsSession, reader: RomReader) {
const old = g.xOffset;
g.xOffset += 2;
if (old >= 9 * 8) {
if (g.var3 === 0) {
session.delay = 2; // .init with VAR3 == 0: no golems left; stop for real.
session.golem = undefined;
return;
}
g.var3--;
g.phase = "fall";
g.var1 = 0x30;
g.xOffset = 0;
g.yOffset = 0;
g.phase = "init"; // .restart
session.scy = 0;
} else if (!(old & 3)) {
g.var2 = -(g.var2 ?? 0) & 0xff;
session.scy = g.var2;
}
}
function golemSprites(reader: RomReader, g: GolemState): ScreenSprite[] {
export function golemSprites(reader: RomReader, g: GolemState): ScreenSprite[] {
const oam = readOamFrame(reader, JOHTO["SpriteAnimOAMData.OAMData_SlotsGolem"]);
const base = CREATURE_BASE_TILE + GOLEM_VTILE[g.frame];
const x = 12 * 8 + g.xOffset,
y = 13 * 8 + g.yOffset;
const base = CREATURE_BASE_TILE + GOLEM_VTILE[GOLEM_FRAMES[g.frame]];
const frameFlip = g.frame === 2 ? 0x40 : g.frame === 3 ? 0x20 : 0;
// `depixel 12, 13` loads D=12*8, E=13*8, but _InitSpriteAnimStruct
// stores SPRITEANIMSTRUCT_XCOORD from E and YCOORD from D (confirmed
// against the reference emulator): the pair ends up swapped from
// depixel's own argument order. Hardware OAM bytes are also 8 (x) and 16
// (y) beyond the screen's (see cursorSprites' own doc comment).
const x = 13 * 8 + g.xOffset,
y = 12 * 8 + g.yOffset;
// Golem's body is left-right symmetric: the OAM table reuses the left
// column's two tiles for the right column, X-flipped (OAM_XFLIP).
return oam.map(({ dy, dx, tile, attr }) => ({
x: x + dx,
y: y + dy,
x: ((x + (frameFlip & 0x20 ? -8 - dx : dx)) & 0xff) - 8,
y: ((y + (frameFlip & 0x40 ? -8 - dy : dy)) & 0xff) - 16,
tile: base + tile,
palette: attr & 7,
flipX: Boolean((attr ^ frameFlip) & 0x20),
flipY: Boolean((attr ^ frameFlip) & 0x40),
}));
}
/** ReelAction_InitChansey's sprite init (depixel 12, 0). */
function initChansey(session: SlotsSession) {
session.chansey = { phase: "walk", xCoord: 12 * 8, var1: 0, frameset: 1, frame: 0, frameTick: 0 };
/** ReelAction_InitChansey's sprite init (depixel 12, 0): XCOORD (which
* `.walk` below moves) comes out as E, i.e. 0, not D's 12*8 (see
* golemSprites' doc comment on the swap). */
export function initChansey(session: SlotsSession) {
session.chansey = { phase: "walk", xCoord: 0, var1: 0, frameset: 1, frame: -1, frameTick: 0 };
}
/** Slots_AnimateChansey plus SpriteAnimFunc_SlotsChansey's own check,
* run once per tick after the reels spin. Walks in, then waits for
@@ -406,48 +437,41 @@ function initChansey(session: SlotsSession) {
* spawns the egg and goes back to waiting: EggAgain (`reelActionStep`'s
* DropReel case) sends the reel back to WaitChansey each time the egg
* rolls to the wrong result, repeating this whole cycle. */
function stepChansey(session: SlotsSession) {
export function stepChansey(session: SlotsSession) {
const c = session.chansey;
if (!c) return;
const frames = c.frameset === 1 ? CHANSEY_FRAMES_1 : CHANSEY_FRAMES_2;
[c.frame, c.frameTick] = advanceFrame(frames, c.frame, c.frameTick);
if (c.phase === "walk") {
c.xCoord++;
if (c.xCoord !== 13 * 8) return;
c.phase = "one";
session.delay = 1; // .limit: ReelAction_WaitChansey can now proceed.
// Falls through to "one", as .limit does.
}
if (c.phase === "one") {
if (session.delay === 5) {
// .one's other check: the whole sequence is over; reset the pose.
c.frame = 0;
c.frameTick = 0;
return;
}
if (session.delay !== 2) return;
c.phase = "retain";
// Falls through to "retain", as .retain does.
}
if (c.phase === "retain") {
c.phase = "two";
c.var1 = 8;
c.frameset = 2;
c.frame = 0;
c.frameTick = 0;
session.delay = 3; // SpriteAnimFunc_SlotsChansey's own extra write.
// Falls through to "two", as .retain does.
}
// .two
if (c.var1 > 0) {
c.var1--;
if (c.removed) {
session.chansey = undefined;
return;
}
// .spawn_egg
c.phase = "one";
session.egg = { counter: 0, xCoord: 12 * 8, yOffset: 0 };
if (c.phase === "walk") {
const old = c.xCoord++;
if (old === 13 * 8) { c.phase = "one"; session.delay = 1; }
}
if (c.phase === "one") {
if (session.delay === 5) c.removed = true;
if (session.delay === 2) { c.phase = "two"; c.var1 = 8; }
}
if (c.phase === "two" && !c.removed) {
if (c.var1) c.var1--;
else {
c.phase = "one";
session.egg = { counter: 0, xCoord: 13 * 8 + 4, yOffset: 0 };
}
}
// SpriteAnimFunc_SlotsChansey runs this after Slots_AnimateChansey.
if (session.delay === 2) {
session.delay = 3;
c.frameset = 2;
c.frame = -1;
c.frameTick = 0;
}
[c.frame, c.frameTick] = advanceFrame(
c.frameset === 1 ? CHANSEY_FRAMES_1 : CHANSEY_FRAMES_2,
c.frame, c.frameTick, c.frameset === 1,
);
}
function chanseySprites(reader: RomReader, c: ChanseyState): ScreenSprite[] {
export function chanseySprites(reader: RomReader, c: ChanseyState): ScreenSprite[] {
const address =
c.frameset === 1
? [
@@ -466,10 +490,10 @@ function chanseySprites(reader: RomReader, c: ChanseyState): ScreenSprite[] {
const oam = readOamFrame(reader, address);
const base = CREATURE_BASE_TILE + CHANSEY_VTILE;
const x = c.xCoord,
y = 0;
y = 12 * 8; // YCOORD (depixel 12, 0's D): fixed for Chansey's whole walk.
return oam.map(({ dy, dx, tile, attr }) => ({
x: x + dx,
y: y + dy,
x: ((x + dx) & 0xff) - 8,
y: ((y + dy) & 0xff) - 16,
tile: base + tile,
palette: attr & 7,
}));
@@ -479,15 +503,19 @@ function chanseySprites(reader: RomReader, c: ChanseyState): ScreenSprite[] {
* update: bounces in place, rolling right every other tick, until it
* reaches x = 15 tiles and sets wSlotsDelay = 4 (ReelAction_WaitEgg's own
* signal to continue into DropReel). */
function stepEgg(session: SlotsSession, reader: RomReader) {
export function stepEgg(session: SlotsSession, reader: RomReader) {
const e = session.egg;
if (!e) return;
if (e.removed) {
session.egg = undefined;
return;
}
const current = e.counter; // SPRITEANIMSTRUCT_JUMPTABLE_INDEX before its own `dec`.
e.counter--;
e.counter = (e.counter - 1) & 0xff;
if (current & 1) {
// .move_right/.move_vertical: odd ticks also advance (or finish) the roll.
if (e.xCoord >= 15 * 8) {
session.egg = undefined;
e.removed = true;
session.delay = 4; // ReelAction_WaitEgg can now continue into DropReel.
return;
}
@@ -496,14 +524,14 @@ function stepEgg(session: SlotsSession, reader: RomReader) {
// .move_vertical: every tick but the finishing one bounces vertically.
e.yOffset = sine(reader, 32, current);
}
function eggSprites(reader: RomReader, e: EggState): ScreenSprite[] {
export function eggSprites(reader: RomReader, e: EggState): ScreenSprite[] {
const oam = readOamFrame(reader, JOHTO["SpriteAnimOAMData.OAMData_1x1_Palette0"]);
const base = CREATURE_BASE_TILE + EGG_VTILE;
const x = e.xCoord,
y = 13 * 8 + 4;
y = 12 * 8 + e.yOffset; // YCOORD (depixel 12, 13, 0, 4's D) plus the sine bounce.
return oam.map(({ dy, dx, tile, attr }) => ({
x: x + dx,
y: y + dy,
x: ((x + dx) & 0xff) - 8,
y: ((y + dy) & 0xff) - 16,
tile: base + tile,
palette: attr & 7,
}));
@@ -989,8 +1017,13 @@ function drawMessage(screen: TileScreen, pages: string[]) {
* redraws, giving the same smooth per-pixel scroll the original draws;
* B_OAM_PRIO clips them to the reel window against the surrounding frame
* (see `behind` on ScreenSprite and tile-screen.ts). */
function reelSprites(reel: Reel, xTile: number): ScreenSprite[] {
const i = reel.position === 0 ? 14 : reel.position - 1;
export function reelSprites(reel: Reel, xTile: number): ScreenSprite[] {
// Slots_UpdateReelPositionAndOAM reads tilemap[position..position+3]
// directly, not position-1 like the win-check window above
// (Slots_GetCurrentReelState/windowAt): confirmed against the reference
// emulator. The tilemap's three repeated trailing entries exist for this
// 4-wide window, up to position 14.
const i = reel.position;
const symbols = [
reel.tilemap[i],
reel.tilemap[i + 1],
@@ -1006,7 +1039,11 @@ function reelSprites(reel: Reel, xTile: number): ScreenSprite[] {
for (let dx = 0; dx < 2; dx++)
for (let dy = 0; dy < 2; dy++)
sprites.push({
x: xTile * 8 + dx * 8,
// REEL_X_COORD is a raw hardware OAM byte, 8 beyond the screen's
// (see cursorSprites' own doc comment in card-flip.ts); the rows
// above are already screen pixels (REEL_Y's own starting raw
// byte, 80, less 16).
x: xTile * 8 - 8 + dx * 8,
y: y + dy * 8,
tile: SYMBOL_BASE_TILE + symbol + dx * 2 + dy,
palette,
@@ -1072,6 +1109,9 @@ function renderSlots(context: SpecialContext, session: SlotsSession, tiles: Uint
if (session.chansey) sprites.push(...chanseySprites(r, session.chansey));
if (session.egg) sprites.push(...eggSprites(r, session.egg));
screen.sprites = sprites;
// _SlotMachine fills the whole BG map with spaces, so the shake shows
// spaces beyond the 18 rows it draws.
if (session.scy) screen.scroll = { y: signed8(session.scy), fill: SPACE_TILE };
return screen;
}
+9 -3
View File
@@ -735,6 +735,7 @@ export class GameWorld {
private turnWait = 0;
/** Start menu, party menu and pack cursors (work RAM, not saved). */
private menuCursors: MenuCursors = { start: 0, party: 0, pocket: 0, dex: 0 };
private seerMemory = { caughtLevel: 0 };
/** OBJECT_ACTION_BUMP this tick: walking in place against an obstacle. */
private bumping = false;
/** A screen shake object (earthquake, step_shake) in progress. */
@@ -2139,6 +2140,7 @@ export class GameWorld {
private host(lastTalked: number, mapId = this.mapId): ScriptHost {
return {
seerMemory: this.seerMemory,
mapId,
lastTalked,
sceneOwner: (id) => this.assets.sceneOwner(id),
@@ -2615,8 +2617,6 @@ export class GameWorld {
const opponent =
effect.trainer &&
trainers.trainer(effect.trainer.classId, effect.trainer.id);
if (opponent?.attributes.items.some((item) => item !== 0))
throw new Error("Trainer item AI is not ported.");
const trainer = opponent && {
name: opponent.name,
className: opponent.className,
@@ -2626,6 +2626,8 @@ export class GameWorld {
}),
aiFlags: opponent.attributes.aiMoveFlags,
switchFlags: opponent.attributes.aiItemSwitchFlags,
items: [...opponent.attributes.items] as [number, number],
baseReward: opponent.attributes.baseReward,
reward: opponent.reward,
winText: effect.trainer?.winText,
lossText: effect.trainer?.lossText,
@@ -3746,7 +3748,10 @@ export class GameWorld {
// reads ObjectEvent's bytes as flag $f251: through echo RAM, bit 1 of
// wNumKeyItems, which is set with the two or three key items usual
// here (BICYCLE, SQUIRTBOTTLE, COIN CASE). The port skips it as that
// case does; what the original does otherwise has not been checked.
// case does. With other counts the original sights it and prints
// ObjectEvent's bytes as seen text, whose first byte runs text
// command $7f beyond TextCommands: a jump into home code that the
// port cannot reproduce (tests/reference-trainer-sight.spec.ts).
if (
object.script === OVERWORLD.ObjectEvent &&
object.eventFlag !== 0xffff &&
@@ -4874,6 +4879,7 @@ export class GameWorld {
this.battleRun = undefined;
this.memScript = undefined;
this.menuCursors = { start: 0, party: 0, pocket: 0, dex: 0 };
this.seerMemory = { caughtLevel: 0 };
this.clearNotice();
this.introPending = false;
// A save made during the introduction starts it again.