Open Routes 35 to 37, the National Park and Ecruteak to the Fog Badge (#32)

Enable Route 35, Route 36, Route 37, the National Park and its gates,
Ecruteak City and its buildings, the Tin Tower entrance, the Wise Trio's
room and both floors of the Burned Tower. The story runs from Floria,
the SquirtBottle and Sudowoodo through the Kimono Girls' HM03 Surf, the
rival in the Burned Tower and the beasts' release to Ecruteak Gym,
Morty, the FOGBADGE and TM30.

Port roaming Pokémon (InitRoamMons, UpdateRoamMons, JumpRoamMons,
CheckEncounterRoamMon) with their state in the save; only Continue moves
them on restore, so a failed import's rollback keeps them in place.
Port the Bug-Catching Contest: registration on contest days, Park Balls,
contestants, the timer, QUIT, the catch-and-swap prompt, judging from
the ROM's contestant table, prizes and the saved contest state.

Port the Game Corner's SlotMachine and CardFlip on a new realtime tile
screen that advances two frames per tick with buffered buttons: reel
strips and scrolling, bias tables, the seven streak, Golem and Chansey,
payouts, the Card Flip board, cursor and navigation. Their tilemaps are
checked against the ROM's own data.

Also port getnum, a plain warp that continues its script on the new map,
and PokemonNames' EGG entry. A trainer whose script is ObjectEvent (the
Burned Tower rival) is no longer sighted once its own flag is set, as
the original's read of bit 1 of wNumKeyItems gives with the usual key
items.

The playthrough walks through the National Park and continues to Morty;
its driver chooses moves by type matchup, buys at Marts and uses Super
Potions and Awakenings in trainer battles. New tests cover roaming, the
contest, the Game Corner and Ecruteak's side content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-12 13:10:17 +02:00
1 parent 0930b780fc
commit 2640fa2725
31 files changed
+4743 -91

No files matched your search

+220 -18
View File
@@ -9,6 +9,7 @@ import {
ENGINE_FLAGS,
EVENTS,
INITIAL_POSITION,
JOHTO,
NUM_FRUIT_TREES,
OVERWORLD,
PC,
@@ -61,6 +62,13 @@ import { IntroRun } from "../scripts/intro";
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen";
import { RTC_DAYS, validDayTimer, type DayTimer } from "./day-timers";
import {
checkEncounterRoamMon,
emptyRoamMons,
jumpRoamMons,
updateRoamMons,
validRoamMon,
} from "./roam-mons";
import { POKEDEX_BUTTONS, type PokedexView } from "../scripts/pokedex";
import { regionAtlas, type AtlasRegion, type AtlasView } from "./atlas";
import {
@@ -89,6 +97,7 @@ import {
} from "../party/storage";
import {
NICKNAME_PATTERN,
PARTY_LENGTH,
caughtData,
validPartyMon,
type PartyMon,
@@ -100,6 +109,7 @@ import {
type BattleEffect,
type BattleInput,
type BattleOutcome,
type RoamMon,
} from "../battle/battle";
import { C } from "../battle/constants";
import {
@@ -188,8 +198,19 @@ export interface NativeSave {
/** RTC day countdowns by name, and the Lucky Number show's number. */
timers: Partial<Record<DayTimerName, DayTimer>>;
luckyNumber: { id: number; day: number };
/** wRoamMon1/2/3 (Raikou, Entei, Suicune). */
roamMons: RoamMon[];
/** wRoamMons_CurMapGroup/Number, kept from the last UpdateRoamMons or
* JumpRoamMons call. Null before the first one (see ScriptState). */
roamMonsMap: string | null;
/** The option bytes, saved with the game as sOptions is. */
options: GameOptions;
/** wParkBallsRemaining/wContestMon and wBugContestStartTime's deadline,
* while a Bug-Catching Contest runs; absent otherwise. */
bugContest?: { parkBalls: number; mon: PartyMon | null };
bugContestEnd?: number;
/** The party slots a running contest hid (see ScriptState). */
contestLeftParty?: PartyMon[];
}
/** A random byte from the platform's secure generator. */
@@ -339,8 +360,10 @@ export type Choice =
| { kind: "options"; view: OptionsView; selected: 0 }
/** The Fly map, answered with the button pressed. */
| { kind: "flymap"; view: FlyMapView; selected: 0 }
/** A tile screen (a minigame), answered with the button pressed. */
| { kind: "screen"; screen: TileScreen; selected: 0 }
/** A tile screen (a minigame), answered with the button pressed. A
* `realtime` screen (the Game Corner) advances on its own every tick
* instead of waiting for an answer; see `bufferScreenButton`. */
| { kind: "screen"; screen: TileScreen; selected: 0; realtime?: boolean }
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| {
kind: "martquantity";
@@ -730,6 +753,11 @@ export class GameWorld {
/** The EMOTE_* whose graphics are in the emote bubble's tiles, which
* show_emote displays. */
private bubbleEmote = 0;
/** Buttons pressed while a realtime screen (the Game Corner) is open,
* consumed one per screen frame instead of answering it immediately, as
* SlotsLoop and CardFlip's MasterLoop poll the joypad once per their own
* video frame. */
private screenInput: string[] = [];
constructor(
readonly assets: CrystalAssets,
@@ -1455,6 +1483,10 @@ export class GameWorld {
if (FACE_DOWN_ON_WARP.has(destination.collision))
this.player.direction = "down";
this.applyMap(destination.entry);
// WarpToNewMapScript's MAPSETUP_DOOR and FallIntoMapScript's
// MAPSETUP_FALL both fall through into MAPSETUP_TRAIN's own
// mapsetup UpdateRoamMons.
this.applyRoamMons(this.state, destination.entry.id, false);
// RefreshMapSprites: a warp leaves the player standing, with the sprite
// CheckUpdatePlayerSprite chooses.
this.turning = 0;
@@ -1540,6 +1572,8 @@ export class GameWorld {
player.x = target.x;
player.y = target.y;
this.applyMap(entry);
// MapSetupScript_Connection's own mapsetup UpdateRoamMons.
this.applyRoamMons(this.state, target.map.id, false);
return;
}
// PlayerEvents: CheckTrainerEvent precedes CheckTileEvent, and a seen
@@ -1638,7 +1672,8 @@ export class GameWorld {
if (state.repel && chosen.level < lead.level) return;
this.startScript(SYMBOLS.wildBattleScript, PLAYER, {
committedPosition: previous,
wild: chosen,
wild: { species: chosen.species, level: chosen.level },
roaming: Boolean(chosen.roamer),
});
// The step replays after a reload, so it keeps its Repel step.
if (state.repel && this.script) this.script.committed.repel++;
@@ -1696,13 +1731,21 @@ export class GameWorld {
}
/** ChooseWildEncounter: 1-100 against GrassMonProbTable or
* WaterMonProbTable, whose entries are (percent, slot offset); surfing
* varies the level. Undefined when no valid Pokémon is chosen. */
* varies the level. Undefined when no valid Pokémon is chosen.
* CheckEncounterRoamMon runs first (never on water) and, when a roaming
* beast is standing on this map, can hijack the whole pick. */
private chooseWildEncounter({
water,
grass,
sea,
}: ReturnType<GameWorld["encounterTable"]>) {
}: ReturnType<GameWorld["encounterTable"]>): { species: number; level: number; roamer?: RoamMon } | undefined {
if (!grass && !sea) return undefined;
if (!water) {
const roamer = checkEncounterRoamMon(this.state.roamMons, this.mapId, () =>
this.random(),
);
if (roamer) return { species: roamer.species, level: roamer.level, roamer };
}
let roll: number;
do roll = this.random();
while (roll >= 100);
@@ -1731,6 +1774,21 @@ export class GameWorld {
return undefined;
return chosen;
}
/** UpdateRoamMons (`jump` false) or JumpRoamMons (`jump` true), then
* _BackUpMapIndices: `currentMap` becomes `roamMonsMap` for the next
* call, regardless of whether any roaming Pokémon actually moved. */
private applyRoamMons(
state: ScriptState,
currentMap: string,
jump: boolean,
) {
const r = this.assets.reader,
random = () => this.random();
state.roamMons = jump
? jumpRoamMons(r, state.roamMons, currentMap, random)
: updateRoamMons(r, state.roamMons, state.roamMonsMap, currentMap, random);
state.roamMonsMap = currentMap;
}
/** CanEncounterWildMon: wildoff; caves and dungeons need no grass; never
* on ice. */
private canEncounterWildMon(state: ScriptState) {
@@ -2087,6 +2145,7 @@ export class GameWorld {
timeOfDay: () => this.clockTimeOfDay,
clock: () => this.clock(),
clockDay: () => this.gameNow().getDay(),
gameNow: () => this.gameNow().getTime(),
text: () => ({
...defaultTextContext(this.name),
rival: this.state.rivalName,
@@ -2195,10 +2254,13 @@ export class GameWorld {
fieldMove,
usedItem,
fish,
roaming,
}: {
usedItem?: number;
/** BATTLETYPE_FISH, set by FishFunction for a hooked Pokémon. */
fish?: boolean;
/** BATTLETYPE_ROAMING, set by CheckEncounterRoamMon. */
roaming?: boolean;
fieldMove?: FieldMoveContext;
committedPosition?: { x: number; y: number };
scene?: boolean;
@@ -2232,6 +2294,7 @@ export class GameWorld {
fieldMove,
usedItem,
fish,
roaming,
},
),
committed,
@@ -2251,6 +2314,8 @@ export class GameWorld {
sighted,
fieldMove,
};
// Any button buffered for an earlier realtime screen cannot still apply.
this.screenInput = [];
this.pump();
}
@@ -2264,10 +2329,12 @@ export class GameWorld {
}
/** Select in PlayerEvents (SelectMenuScript): the registered item. */
useSelect(): boolean {
if (
this.dialogue?.choice?.kind === "pokegear" ||
this.dialogue?.choice?.kind === "screen"
) {
const choice = this.dialogue?.choice;
if (choice?.kind === "screen" && choice.realtime) {
this.bufferScreenButton("select");
return true;
}
if (choice?.kind === "pokegear" || choice?.kind === "screen") {
this.choose("select");
return true;
}
@@ -2280,10 +2347,14 @@ export class GameWorld {
/** Start in PlayerEvents (StartMenuScript): the start menu opens while
* the player stands still. Its changes commit when it closes. */
openStartMenu(select = false): boolean {
const choice = this.dialogue?.choice;
if (!select && choice?.kind === "screen" && choice.realtime) {
this.bufferScreenButton("start");
return true;
}
if (
!select &&
(this.dialogue?.choice?.kind === "pokegear" ||
this.dialogue?.choice?.kind === "screen")
(choice?.kind === "pokegear" || choice?.kind === "screen")
) {
this.choose("start");
return true;
@@ -2577,6 +2648,12 @@ export class GameWorld {
fish: effect.fish,
battleType: effect.battleType,
contest: draft.bugContest,
// GetRoamMonSpecies and the like identify a roaming battle's own
// struct by species, exactly as the original's helpers do.
roamer:
effect.battleType === C.BATTLETYPE_ROAMING
? draft.roamMons.find((mon) => mon.species === effect.wild?.species)
: undefined,
mapId: this.mapId,
text: () => this.host(PLAYER).text(),
bag: draft.bag,
@@ -2625,7 +2702,12 @@ export class GameWorld {
this.dialogue = {
pages: [""],
index: 0,
choice: { kind: "screen", screen: effect.screen, selected: 0 },
choice: {
kind: "screen",
screen: effect.screen,
selected: 0,
...(effect.realtime && { realtime: true }),
},
};
return true;
case "fly":
@@ -3125,7 +3207,7 @@ export class GameWorld {
private finishScript() {
const script = this.script!;
if (script.warp) {
const { map, x, y, facing, door } = script.warp;
const { map, x, y, facing, door, roam } = script.warp;
const target = this.assets.isPorted(map)
? this.assets.map(map)
: undefined;
@@ -3146,6 +3228,7 @@ export class GameWorld {
motion: undefined,
};
this.applyMap(entry);
if (roam) this.applyRoamMons(this.state, map, roam === "jump");
this.turning = 0;
this.checkUpdatePlayerSprite(this.state);
} else {
@@ -3249,12 +3332,22 @@ export class GameWorld {
if (next.done) {
if (this.script) {
const draft = this.script.run.state,
{ prizeMoney = 0, momMoney = 0, payDayMoney = 0 } = next.value;
{
prizeMoney = 0,
momMoney = 0,
payDayMoney = 0,
updateRoamMons,
} = next.value;
draft.money = Math.min(
C.MAX_MONEY,
draft.money + prizeMoney + payDayMoney,
);
draft.momsMoney = Math.min(C.MAX_MONEY, draft.momsMoney + momMoney);
// CleanUpBattleRAM's callfar UpdateRoamMons, which the original
// runs after EvolveAfterBattle below; the two touch unrelated
// state, so committing it here first changes nothing observable.
if (updateRoamMons)
this.applyRoamMons(draft, this.mapId, false);
}
this.battle = undefined;
this.battleRun = undefined;
@@ -3367,6 +3460,17 @@ export class GameWorld {
for (const flag of this.dailyFlags) this.state.flags.delete(flag);
this.state.dailyReset = day;
}
/** CheckBugContestTimer, from CheckTimeEvents while
* STATUSFLAGS2_BUG_CONTEST_TIMER_F is set: GiveParkBalls' deadline
* replaces the ROM's wBugContestStartTime day/hour/min/sec struct (see
* ScriptState.bugContestEnd); past it, BugCatchingContestOverScript
* runs in place of the player's own input this tick. */
private checkBugContestTimer() {
const end = this.state.bugContestEnd;
if (end === undefined || this.gameNow(this.state.clock).getTime() < end)
return;
this.startScript(JOHTO.BugCatchingContestOverScript, PLAYER);
}
// ---------------------------------------------------------------- NPCs
/** HandleNPCStep: every spawned object runs its step type once per tick
@@ -3636,6 +3740,19 @@ export class GameWorld {
if (object.kind !== OBJECTTYPE_TRAINER || !this.spawned.has(object.id))
continue;
if (this.refusedSighting?.id === object.id) continue;
// BurnedTower1F's rival is a trainer whose script is ObjectEvent, not
// a TrainerHeader. Once its battle has set its own flag, it still
// faces the player beside the hole, and the original's "beaten" check
// 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.
if (
object.script === OVERWORLD.ObjectEvent &&
object.eventFlag !== 0xffff &&
this.state.events.has(object.eventFlag)
)
continue;
const actor = this.actor(object);
// FacingPlayerDistance: same row or column, facing the player.
let distance: number, facing: Direction;
@@ -3733,6 +3850,13 @@ export class GameWorld {
}
const dialogue = this.dialogue;
if (dialogue) {
if (dialogue.choice?.kind === "screen" && dialogue.choice.realtime) {
// SlotsLoop/CardFlip's MasterLoop poll the joypad and redraw once
// per video frame; two frames run per overworld tick, as elsewhere.
this.advanceScreenFrame();
this.advanceScreenFrame();
return;
}
if (
dialogue.holdTicks !== undefined &&
dialogue.index === dialogue.pages.length - 1
@@ -3827,7 +3951,10 @@ export class GameWorld {
}
this.checkScene();
if (this.busy) return;
this.checkDailyReset();
// CheckTimeEvents: a running contest skips the daily path entirely.
if (this.state.bugContest) this.checkBugContestTimer();
else this.checkDailyReset();
if (this.busy) return;
// PlayerEvents precede HandleMapObjects: a step starts and moves at once.
const stepped = this.beginStep(held);
if (this.script || this.battle || this.dialogue) return;
@@ -4312,7 +4439,10 @@ export class GameWorld {
(dialogue.choice.kind === "flymap" &&
!FLY_MAP_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "screen" &&
!SCREEN_BUTTONS.includes(answer as never))
!SCREEN_BUTTONS.includes(answer as never) &&
// A realtime screen's own frame-advance passes "" for a frame with
// no button pressed, which SCREEN_BUTTONS does not list.
!(dialogue.choice.realtime && answer === ""))
)
return;
this.townMap = false;
@@ -4321,6 +4451,20 @@ export class GameWorld {
this.dialogue = undefined;
this.pump(answer);
}
/** A button pressed while a realtime screen is open: buffered for its
* next frame rather than answered at once. Bounded so a screen left open
* while busy elsewhere cannot grow this without limit. */
bufferScreenButton(button: string) {
if (this.screenInput.length < 8) this.screenInput.push(button);
}
/** One video frame of a realtime screen (SlotsLoop, CardFlip's
* MasterLoop): resume it with the next buffered button, or none. */
private advanceScreenFrame() {
const choice = this.dialogue?.choice;
if (!this.script || !choice || choice.kind !== "screen" || !choice.realtime)
return;
this.choose(this.screenInput.shift() ?? "");
}
// ---------------------------------------------------------------- saves
@@ -4398,10 +4542,25 @@ export class GameWorld {
firstUnownSeen: state.firstUnownSeen,
timers: structuredClone(state.timers),
luckyNumber: { ...state.luckyNumber },
roamMons: structuredClone(state.roamMons),
roamMonsMap: state.roamMonsMap,
options: { ...state.options },
...(state.bugContest && {
bugContest: {
parkBalls: state.bugContest.parkBalls,
mon: structuredClone(state.bugContest.mon),
},
bugContestEnd: state.bugContestEnd,
}),
...(state.contestLeftParty && {
contestLeftParty: structuredClone(state.contestLeftParty),
}),
};
}
restore(value: unknown) {
/** Load a save. `continued` is Continue from the title menu, which
* relocates the roaming beasts; a rollback to a captured state passes
* false so the state comes back exactly. */
restore(value: unknown, continued = true) {
const save = migrateSave(value, () => (this.random() << 8) | this.random());
const integer = (
value: unknown,
@@ -4539,7 +4698,28 @@ export class GameWorld {
!save.luckyNumber ||
!integer(save.luckyNumber.id, 0, 0xffff) ||
!integer(save.luckyNumber.day, 0, RTC_DAYS) ||
!validOptions(save.options)
!Array.isArray(save.roamMons) ||
save.roamMons.length !== 3 ||
!save.roamMons.every(validRoamMon) ||
(save.roamMonsMap !== null &&
(typeof save.roamMonsMap !== "string" ||
!/^\d{1,2}:\d{1,3}$/.test(save.roamMonsMap))) ||
!validOptions(save.options) ||
(save.bugContest === undefined) !== (save.bugContestEnd === undefined) ||
(save.bugContest !== undefined &&
(typeof save.bugContest !== "object" ||
save.bugContest === null ||
!integer(save.bugContest.parkBalls, 0, 20) ||
(save.bugContest.mon !== null &&
!validPartyMon(this.assets.pokemon, save.bugContest.mon)))) ||
(save.bugContestEnd !== undefined &&
!integer(save.bugContestEnd, 0, Number.MAX_SAFE_INTEGER)) ||
(save.contestLeftParty !== undefined &&
(!Array.isArray(save.contestLeftParty) ||
save.contestLeftParty.length > PARTY_LENGTH - 1 ||
!save.contestLeftParty.every((mon) =>
validPartyMon(this.assets.pokemon, mon),
)))
) {
throw new Error(
"Invalid native save. Emulator snapshots cannot be imported into this port.",
@@ -4617,10 +4797,26 @@ export class GameWorld {
firstUnownSeen: save.firstUnownSeen,
timers: structuredClone(save.timers),
luckyNumber: { ...save.luckyNumber },
roamMons: structuredClone(save.roamMons),
roamMonsMap: save.roamMonsMap,
options: { ...save.options },
specialPhoneCall: save.specialPhoneCall,
rivalName: save.rivalName,
...(save.bugContest && {
bugContest: {
parkBalls: save.bugContest.parkBalls,
mon: structuredClone(save.bugContest.mon),
},
bugContestEnd: save.bugContestEnd,
}),
...(save.contestLeftParty && {
contestLeftParty: structuredClone(save.contestLeftParty),
}),
};
// Continue's own farcall JumpRoamMons, run directly before
// MAPSETUP_CONTINUE itself: the well-known trick of saving and
// reloading the game to relocate the roaming beasts.
if (continued) this.applyRoamMons(this.state, save.map, true);
let entry;
try {
entry = this.prepareMap(save.map, "continue");
@@ -4756,6 +4952,10 @@ export function migrateSave(
timers: {},
luckyNumber: { id: 0, day: 0 },
}),
...(!Object.hasOwn(save, "roamMons") && {
roamMons: emptyRoamMons(),
roamMonsMap: null,
}),
...(!Object.hasOwn(save, "intro") && { intro: false }),
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
@@ -4815,6 +5015,8 @@ export function migrateSave(
firstUnownSeen: 0,
timers: {},
luckyNumber: { id: 0, day: 0 },
roamMons: emptyRoamMons(),
roamMonsMap: null,
intro: false,
gameTime: emptyGameTime(),
options: defaultOptions(),