Port field moves from the menu, SEL with Select, and moving Pokémon in Bill's PC
Checks / web (push) Canceled after 0s

Field moves chosen in the party menu run their original checks and the
ROM's from-menu scripts (Cut, Surf, Strength, Flash, Whirlpool, Waterfall,
Sweet Scent), and Softboiled and Milk Drink share HP. SEL registers a key
item that Select then uses, replacing the Key Items shortcut, and Bill's
PC moves Pokémon between the party and every box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 17:22:56 +02:00
1 parent 45275ba83b
commit 3456aa4e11
25 files changed
+1129 -186

No files matched your search

+232 -24
View File
@@ -7,6 +7,7 @@ import { CrystalAssets } from "../rom/assets";
import { TrainerData } from "../rom/trainers";
import {
ENGINE_FLAGS,
EVENTS,
INITIAL_POSITION,
NUM_FRUIT_TREES,
OVERWORLD,
@@ -27,7 +28,11 @@ import {
type ScriptState,
} from "../scripts/interpreter";
import { IntroRun } from "../scripts/intro";
import { StartMenuRun, type MenuCursors } from "../scripts/start-menu";
import {
StartMenuRun,
type MenuCursors,
type MenuFieldMove,
} from "../scripts/start-menu";
import { PartyEvolution } from "../scripts/evolution";
import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
@@ -107,6 +112,8 @@ export interface NativeSave {
/** STATUSFLAGS_FLASH_F and STATUSFLAGS_NO_WILD_ENCOUNTERS_F. */
flash: boolean;
noWildEncounters: boolean;
/** wWhichRegisteredItem and wRegisteredItem, for Select. */
registeredItem: { which: number; item: number };
}
/** A random byte from the platform's secure generator. */
@@ -1364,7 +1371,20 @@ export class GameWorld {
)
return;
if (isIce(collision)) return;
// GetMapEncounterRate: CheckOnWater picks the water rate.
const table = this.encounterTable(state);
if (!(this.random() < table.rate)) return;
const chosen = this.chooseWildEncounter(table);
if (!chosen) return;
if (!state.party.some((mon) => mon.hp > 0)) return;
this.startScript(SYMBOLS.wildBattleScript, PLAYER, {
committedPosition: previous,
wild: chosen,
});
}
/** GetMapEncounterRate: CheckOnWater picks the water table and rate,
* which a Cleanse Tag halves. */
private encounterTable(state: ScriptState) {
const collision = this.assets.collision(this.map, this.x, this.y);
const water = this.assets.permission(collision) === OW.WATER_TILE,
kanto = this.inKanto();
const grass = water
@@ -1376,10 +1396,17 @@ export class GameWorld {
let rate = (water ? sea?.rate : grass?.rates[this.clockTimeOfDay]) ?? 0;
// ApplyCleanseTagEffectOnEncounterRate
if (state.party.some((mon) => mon.item === C.CLEANSE_TAG)) rate >>= 1;
if (!(this.random() < rate)) return;
// ChooseWildEncounter: 1-100 against GrassMonProbTable or
// WaterMonProbTable, whose entries are (percent, slot offset).
if (!grass && !sea) return;
return { water, grass, sea, rate };
}
/** 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. */
private chooseWildEncounter({
water,
grass,
sea,
}: ReturnType<GameWorld["encounterTable"]>) {
if (!grass && !sea) return undefined;
let roll: number;
do roll = this.random();
while (roll >= 100);
@@ -1399,12 +1426,167 @@ export class GameWorld {
}
// ValidateTempWildMonSpecies; Unown needs unlocked Unown (not ported).
if (!chosen.species || chosen.species > 251 || chosen.species === C.UNOWN)
return;
if (!state.party.some((mon) => mon.hp > 0)) return;
this.startScript(SYMBOLS.wildBattleScript, PLAYER, {
committedPosition: previous,
wild: chosen,
});
return undefined;
return chosen;
}
/** CanEncounterWildMon: wildoff; caves and dungeons need no grass; never
* on ice. */
private canEncounterWildMon(state: ScriptState) {
const map = this.map,
collision = this.assets.collision(map, this.x, this.y);
return (
!state.noWildEncounters &&
(map.environment === OW.CAVE ||
map.environment === OW.DUNGEON ||
this.assets.grassCollisions().includes(collision)) &&
!isIce(collision)
);
}
/** A field move chosen from the party menu (CutFunction, SurfFunction and
* the rest in engine/events/overworld.asm), checked on the menu's draft
* for party slot `mon`: a script queued for when the menu closes, the
* messages of a failed attempt, or a notice for what is not ported. */
private menuFieldMove(
state: ScriptState,
mon: number,
move: number,
): MenuFieldMove {
const [dx, dy] = VECTORS[this.player.direction];
const x = this.player.x + dx,
y = this.player.y + dy;
const tile = this.assets.collision(this.map, x, y) ?? 0;
const env = this.map.environment,
outdoors = env === OW.TOWN || env === OW.ROUTE;
const failed = { texts: [OVERWORLD["FieldMoveFailed.CantUseItemText"]] };
const badge = (flag: number) =>
state.flags.has(flag)
? undefined
: { texts: [OVERWORLD["CheckBadge.BadgeRequiredText"]] };
const facingObject = () =>
this.objects().find((object) => object.x === x && object.y === y);
switch (move) {
case OW.CUT: {
const block = this.fieldMoveBlock(x, y, tile, "cut");
return (
badge(OW.ENGINE_HIVEBADGE) ??
(block
? {
script: OVERWORLD.Script_CutFromMenu,
fieldMove: { mon, block },
}
: { texts: [OVERWORLD.CutNothingText] })
);
}
case OW.SURF: {
const noBadge = badge(OW.ENGINE_FOGBADGE);
if (noBadge) return noBadge;
const cant = { texts: [OVERWORLD.CantSurfText] };
if (state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)) return cant;
if (
state.playerState === OW.PLAYER_SURF ||
state.playerState === OW.PLAYER_SURF_PIKA
)
return { texts: [OVERWORLD.AlreadySurfingText] };
const current = this.assets.collision(this.map, this.x, this.y);
if (
this.assets.permission(tile) !== OW.WATER_TILE ||
this.sideBlocked(current, tile, this.player.direction) ||
facingObject()
)
return cant;
return {
script: OVERWORLD.SurfFromMenuScript,
fieldMove: {
mon,
// GetSurfType: Pikachu surfs on its own sprite.
surfState:
state.party[mon].species === OW.PIKACHU
? OW.PLAYER_SURF_PIKA
: OW.PLAYER_SURF,
},
};
}
case OW.STRENGTH:
return (
badge(OW.ENGINE_PLAINBADGE) ?? {
script: OVERWORLD.Script_StrengthFromMenu,
fieldMove: { mon },
}
);
case OW.FLASH: {
const noBadge = badge(OW.ENGINE_ZEPHYRBADGE);
if (noBadge) return noBadge;
// SpecialAerodactylChamber opens its wall; otherwise the map must
// be dark (DARKNESS_PALSET).
const chamber =
this.map.attributes ===
OVERWORLD.RuinsOfAlphAerodactylChamber_MapAttributes;
if (chamber) state.events.add(EVENTS.wallOpenedInAerodactylChamber);
return chamber || this.timeOfDay === 3
? { script: OVERWORLD.Script_UseFlash, fieldMove: { mon } }
: failed;
}
case OW.WHIRLPOOL: {
const noBadge = badge(OW.ENGINE_GLACIERBADGE);
if (noBadge) return noBadge;
const block = isWhirlpool(tile)
? this.fieldMoveBlock(x, y, tile, "whirlpool")
: undefined;
return block
? {
script: OVERWORLD.Script_WhirlpoolFromMenu,
fieldMove: { mon, block },
}
: failed;
}
case OW.WATERFALL: {
const noBadge = badge(OW.ENGINE_RISINGBADGE);
if (noBadge) return noBadge;
// CheckMapCanWaterfall: facing up at a waterfall tile.
return this.player.direction === "up" &&
(tile === OW.COLL_WATERFALL || tile === OW.COLL_CURRENT_DOWN)
? { script: OVERWORLD.Script_WaterfallFromMenu, fieldMove: { mon } }
: failed;
}
case OW.FLY:
return (
badge(OW.ENGINE_STORMBADGE) ??
(outdoors ? { notice: "The Fly map is not ported yet." } : failed)
);
case OW.DIG:
return env === OW.CAVE || env === OW.DUNGEON
? { notice: "Digging out of a cave is not ported yet." }
: { texts: [OVERWORLD["EscapeRopeOrDig.CantUseDigText"]] };
case OW.TELEPORT:
return outdoors && state.lastSpawnMap !== null
? { notice: "Teleport is not ported yet." }
: { texts: [OVERWORLD["TeleportFunction.CantUseTeleportText"]] };
case OW.HEADBUTT:
return tile === OW.COLL_HEADBUTT_TREE ||
tile === OW.COLL_HEADBUTT_TREE_1D
? { notice: "Headbutt trees are not ported yet." }
: failed;
case OW.ROCK_SMASH:
return facingObject()?.movement === OW.SPRITEMOVEDATA_SMASHABLE_ROCK
? { notice: "Smashing rocks is not ported yet." }
: failed;
case OW.SWEET_SCENT: {
// SweetScentEncounter, drawn now: no rate roll, and no battle
// without a rate or a valid Pokémon.
let wild: { species: number; level: number } | undefined;
if (this.canEncounterWildMon(state)) {
const table = this.encounterTable(state);
if (table.rate) wild = this.chooseWildEncounter(table);
}
return {
script: OVERWORLD["SweetScentFromMenu.SweetScent"],
fieldMove: { mon },
wild,
};
}
}
return { notice: "This field move is not ported yet." };
}
/** IsInJohto: Kanto landmarks start at KANTO_LANDMARK; the Fast Ship
@@ -1655,9 +1837,13 @@ export class GameWorld {
text: () => this.host(PLAYER).text(),
};
}
/** Select in PlayerEvents (SelectMenuScript): the registered item. */
useSelect(): boolean {
return this.openStartMenu(true);
}
/** Start in PlayerEvents (StartMenuScript): the start menu opens while
* the player stands still. Its changes commit when it closes. */
openStartMenu(): boolean {
openStartMenu(select = false): boolean {
if (this.busy || this.introPending) return false;
this.clearNotice();
this.script = {
@@ -1667,11 +1853,14 @@ export class GameWorld {
reader: this.assets.reader,
data: this.assets.pokemon,
text: () => this.host(PLAYER).text(),
bicycleScript: (state) => this.bicycleScript(state),
bicycleScript: (state, select) => this.bicycleScript(state, select),
timeOfDay: () => this.clockTimeOfDay,
landmark: () => this.assets.map(this.mapId).landmark,
fieldMove: (state, mon, move) =>
this.menuFieldMove(state, mon, move),
},
this.menuCursors,
select,
),
committed: this.save(),
rollback: {
@@ -2396,12 +2585,17 @@ export class GameWorld {
this.sceneCheck = true;
if (script.run instanceof StartMenuRun) this.menuCursors = script.run.cursors;
if (script.run.deferred !== undefined)
this.startScript(script.run.deferred, PLAYER, {
usedItem:
script.run instanceof StartMenuRun
? script.run.deferredItem
: undefined,
});
this.startScript(
script.run.deferred,
PLAYER,
script.run instanceof StartMenuRun
? {
usedItem: script.run.deferredItem,
fieldMove: script.run.deferredMove?.fieldMove,
wild: script.run.deferredMove?.wild,
}
: {},
);
}
private abortScript(error: unknown) {
const script = this.script!;
@@ -3228,7 +3422,7 @@ export class GameWorld {
}
/** BikeFunction.TryBike: the script QueueScript runs, or undefined when
* .CheckEnvironment or the player's state refuses the Bicycle. */
private bicycleScript(state: ScriptState): number | undefined {
private bicycleScript(state: ScriptState, select = false): number | undefined {
const map = this.map;
const outdoors =
map.environment === OW.TOWN || map.environment === OW.ROUTE;
@@ -3239,12 +3433,17 @@ export class GameWorld {
this.assets.permission(this.assets.collision(map, this.x, this.y)) ===
OW.LAND_TILE;
if (!allowed) return undefined;
// .CheckIfRegistered: Select uses the scripts without a message.
if (state.playerState === OW.PLAYER_NORMAL)
return OVERWORLD.Script_GetOnBike;
return select
? OVERWORLD.Script_GetOnBike_Register
: OVERWORLD.Script_GetOnBike;
if (state.playerState === OW.PLAYER_BIKE)
return state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)
? OVERWORLD.Script_CantGetOffBike
: OVERWORLD.Script_GetOffBike;
: select
? OVERWORLD.Script_GetOffBike_Register
: OVERWORLD.Script_GetOffBike;
return undefined;
}
@@ -3484,6 +3683,7 @@ export class GameWorld {
backupWarp: state.backupWarp && { ...state.backupWarp },
flash: state.flash,
noWildEncounters: state.noWildEncounters,
registeredItem: { ...state.registeredItem },
};
}
restore(value: unknown) {
@@ -3579,7 +3779,10 @@ export class GameWorld {
!/^\d{1,2}:\d{1,3}$/.test(save.backupWarp.map) ||
!integer(save.backupWarp.warp, 1, 254))) ||
typeof save.flash !== "boolean" ||
typeof save.noWildEncounters !== "boolean"
typeof save.noWildEncounters !== "boolean" ||
!save.registeredItem ||
!integer(save.registeredItem.which, 0, 255) ||
!integer(save.registeredItem.item, 0, 255)
) {
throw new Error(
"Invalid native save. Emulator snapshots cannot be imported into this port.",
@@ -3641,6 +3844,7 @@ export class GameWorld {
backupWarp: save.backupWarp && { ...save.backupWarp },
flash: save.flash,
noWildEncounters: save.noWildEncounters,
registeredItem: { ...save.registeredItem },
specialPhoneCall: save.specialPhoneCall,
rivalName: save.rivalName,
};
@@ -3738,6 +3942,9 @@ export function migrateSave(
...(!Object.hasOwn(save, "noWildEncounters") && {
noWildEncounters: false,
}),
...(!Object.hasOwn(save, "registeredItem") && {
registeredItem: { which: 0, item: 0 },
}),
...(!Object.hasOwn(save, "intro") && { intro: false }),
// Clocks saved before InitClock was ported follow the device time.
...(save.clock !== null &&
@@ -3779,6 +3986,7 @@ export function migrateSave(
backupWarp: null,
flash: false,
noWildEncounters: false,
registeredItem: { which: 0, item: 0 },
intro: false,
} as unknown as NativeSave;
}