Open Route 42 to Mahogany, the Lake of Rage, the Rocket Hideout and Pryce (#32)

Route 42, Mt. Mortar, Mahogany Town, Route 43 and its gates, the Lake of
Rage and its houses, the Rocket Hideout and Route 44 are open. The story
plays through the toll, the red Gyarados, Lance, the hideout and Pryce's
GLACIERBADGE, which starts the Radio Tower takeover.

The fishing guru measures Magikarp (CheckMagikarpLength,
MagikarpHouseSign, FindPartyMonThatSpecies); the record is saved.
takemoney now leaves a short account at zero as TakeMoney does, and
setmapscene/setscene on a map without scenes consume the byte instead of
refusing. The playthrough's path search slides on ice and the run gains
five stages to Pryce.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-13 13:35:58 +02:00
1 parent 62fe4bf664
commit 061ca4d59d
11 files changed
+578 -43

No files matched your search

+28
View File
@@ -1064,6 +1064,12 @@ 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 = {
CheckMagikarpLength: 0xfbb32,
MagikarpHouseSign: 0xfbcd2,
FindPartyMonThatSpecies: 0xc276,
PlayCurMonCry: 0xc472,
"CheckMagikarpLength.MagikarpGuruMeasureText": 0xfbba9,
"MagikarpHouseSign.KarpGuruRecordText": 0xfbce8,
"GiveShuckle": 0x7305,
"ReturnShuckie": 0x737e,
"PokeSeer": 0x4f0bc,
@@ -1279,6 +1285,7 @@ export const WRAM = {
wBugContestFirstPlaceScore: 0xd004,
wBugContestSecondPlaceScore: 0xd008,
wBugContestThirdPlaceScore: 0xd00c,
wMagikarpRecordHoldersName: 0xdfea,
} as const;
export const MAP_GROUP_COUNT = 26;
@@ -1295,6 +1302,27 @@ 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> = {
"2:1": "Red Gyarados speech house",
"2:2": "Mahogany Gym",
"2:3": "Pokémon Center",
"2:4": "Route 42 Ecruteak gate",
"2:5": "Route 42",
"2:6": "Route 44",
"2:7": "Mahogany Town",
"3:48": "Mahogany souvenir shop",
"3:49": "Rocket Hideout B1F",
"3:50": "Rocket Hideout B2F",
"3:51": "Rocket Hideout B3F",
"3:57": "Mt. Mortar",
"3:58": "Mt. Mortar",
"3:59": "Mt. Mortar 2F",
"3:60": "Mt. Mortar B1F",
"9:1": "Hidden Power house",
"9:2": "Fishing guru’s house",
"9:3": "Route 43 Mahogany gate",
"9:4": "Route 43 gate",
"9:5": "Route 43",
"9:6": "Lake of Rage",
"1:1": "Olivine Pokecenter 1F",
"1:2": "Olivine Gym",
"1:3": "Olivine Tims House",
+39 -7
View File
@@ -72,6 +72,13 @@ import { unownPuzzle } from "./unown-puzzle";
import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins";
import { slotMachine } from "./slot-machine";
import { cardFlip } from "./card-flip";
import {
checkMagikarpLength,
findPartyMonThatSpecies,
initialMagikarpRecord,
magikarpHouseSign,
type MagikarpRecord,
} from "./lake-of-rage";
import {
checkFirstMonIsEgg,
checkForLuckyNumberWinners,
@@ -187,6 +194,8 @@ export interface ScriptState {
timers: Partial<Record<DayTimerName, DayTimer>>;
/** sLuckyIDNumber and sLuckyNumberDay (wCurDay + 1 when drawn). */
luckyNumber: { id: number; day: number };
/** wBestMagikarpLengthFeet/Inches and wMagikarpRecordHoldersName. */
magikarpRecord: MagikarpRecord;
/** wRoamMon1/2/3 (Raikou, Entei, Suicune): InitRoamMons starts the first
* two roaming; CheckEncounterRoamMon, UpdateRoamMons and JumpRoamMons
* move or encounter them (see ../world/roam-mons). */
@@ -255,6 +264,7 @@ export const emptyScriptState = (): ScriptState => ({
firstUnownSeen: 0,
timers: {},
luckyNumber: { id: 0, day: 0 },
magikarpRecord: initialMagikarpRecord(),
roamMons: emptyRoamMons(),
roamMonsMap: null,
options: defaultOptions(),
@@ -303,6 +313,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
Object.entries(state.timers).map(([name, timer]) => [name, { ...timer }]),
),
luckyNumber: { ...state.luckyNumber },
magikarpRecord: { ...state.magikarpRecord },
roamMons: state.roamMons.map((mon) => ({ ...mon })),
roamMonsMap: state.roamMonsMap,
options: { ...state.options },
@@ -1114,9 +1125,11 @@ export class ScriptRun {
if (opcode === 0x11)
value = owner ? (this.state.scenes[owner] ?? 0) : 0xff;
else {
if (!owner)
throw new Error("setmapscene targets a map without scenes.");
this.state.scenes[owner] = byte();
// DoScene: GetMapSceneID leaves de as it was for a map without
// a scene variable, and the scene byte is still read and
// written there when de is nonzero (see the 0x14 case below).
const scene = byte();
if (owner) this.state.scenes[owner] = scene;
}
break;
}
@@ -1127,8 +1140,15 @@ export class ScriptRun {
}
case 0x14: {
const owner = sceneOwner(this.host.mapId);
if (!owner) throw new Error("setscene on a map without scenes.");
this.state.scenes[owner] = byte();
// A map without a scene variable (Route 43, the Lake of Rage):
// GetMapSceneID returns carry but leaves de alone, and DoScene
// writes the byte through de whenever it is nonzero. At the Route
// 43 gate de is EventFlagAction's byte offset (4), the MBC's
// RAM-enable register; at the Lake of Rage it is whatever
// closetext left. Either way the byte is consumed and no game
// state changes here.
const scene = byte();
if (owner) this.state.scenes[owner] = scene;
break;
}
case 0x15:
@@ -1709,8 +1729,9 @@ export class ScriptRun {
if (opcode === 0x22)
this.state[account] = Math.min(C.MAX_MONEY, balance + amount);
else if (opcode === 0x23) {
// TakeMoney leaves the account unchanged when it is short.
if (balance >= amount) this.state[account] = balance - amount;
// TakeMoney: SubtractFunds keeps de on the account, which is
// zeroed when the subtraction borrows ("leave with 0 money").
this.state[account] = Math.max(0, balance - amount);
} else
value =
balance > amount
@@ -3389,8 +3410,19 @@ export class ScriptRun {
yield* photoStudio(this.specialContext(pages, take));
return value;
case JOHTO.PlaySlowCry:
case JOHTO.PlayCurMonCry:
// Cry pitch/duration is audio, like the ordinary cry opcode.
return value;
case JOHTO.FindPartyMonThatSpecies:
return findPartyMonThatSpecies(this.specialContext(pages, take), value);
case JOHTO.CheckMagikarpLength:
return yield* checkMagikarpLength(
this.specialContext(pages, take),
this.state.magikarpRecord,
);
case JOHTO.MagikarpHouseSign:
magikarpHouseSign(this.specialContext(pages, take), this.state.magikarpRecord);
return value;
case JOHTO.GameCornerPrizeMonCheckDex:
if (this.state.pokedex.caught.has(value)) return value;
this.state.pokedex.seen.add(value);
+68
View File
@@ -0,0 +1,68 @@
import { JOHTO, RAM, WRAM } from "../rom/profile";
import { magikarpLength } from "../battle/battle";
import type { Gen, SpecialContext } from "./special-context";
const MAGIKARP = 129;
/** MAGIKARPLENGTH_* (constants/script_constants.asm). */
export const MAGIKARP_LENGTH = {
notMagikarp: 0,
refused: 1,
tooShort: 2,
beatRecord: 3,
} as const;
/** wBestMagikarpLengthFeet/Inches and wMagikarpRecordHoldersName;
* InitializeMagikarpHouse starts a new game at 3′6″ by RALPH. */
export interface MagikarpRecord {
feet: number;
inches: number;
holder: string;
}
export const initialMagikarpRecord = (): MagikarpRecord => ({
feet: 3,
inches: 6,
holder: "RALPH",
});
/** PrintMagikarpLength: feet and inches, each a left-aligned number, with
* the feet and inches marks it loads into tiles $6e and $6f. */
const lengthString = (feet: number, inches: number) => `${feet}′${inches}″`;
/** FindThatSpecies: wPartySpecies holds EGG for an Egg, so an Egg never
* matches. */
export const findPartyMonThatSpecies = (c: SpecialContext, species: number) =>
c.state.party.some((mon) => !mon.egg && mon.species === species) ? 1 : 0;
/** CheckMagikarpLength (engine/events/magikarp.asm): pick a party
* Pokémon; a Magikarp is measured from its DVs and its own OT ID, and a
* longer one (both bytes compared, feet first) becomes the record, with
* its OT's name as the holder. */
export function* checkMagikarpLength(
c: SpecialContext,
record: MagikarpRecord,
): Gen<number> {
const slot = yield* c.selectMon();
if (slot < 0) return MAGIKARP_LENGTH.refused;
const mon = c.state.party[slot];
// wCurPartySpecies comes from wPartySpecies, EGG for an Egg.
if (mon.egg || mon.species !== MAGIKARP) return MAGIKARP_LENGTH.notMagikarp;
const { feet, inches } = magikarpLength(c.reader, mon.dvs, mon.otId);
c.strings[RAM.stringBuffer1] = lengthString(feet, inches);
c.write(JOHTO["CheckMagikarpLength.MagikarpGuruMeasureText"]);
// CompareBytes over wMagikarpLength and the record: carry when the new
// length is greater.
if (feet * 256 + inches <= record.feet * 256 + record.inches)
return MAGIKARP_LENGTH.tooShort;
record.feet = feet;
record.inches = inches;
record.holder = mon.ot;
return MAGIKARP_LENGTH.beatRecord;
}
/** MagikarpHouseSign: the record and its holder. */
export function magikarpHouseSign(c: SpecialContext, record: MagikarpRecord) {
c.strings[RAM.stringBuffer1] = lengthString(record.feet, record.inches);
c.strings[WRAM.wMagikarpRecordHoldersName] = record.holder;
c.write(JOHTO["MagikarpHouseSign.KarpGuruRecordText"]);
}
+14
View File
@@ -58,6 +58,7 @@ import {
type ScriptState,
type DayTimerName,
} from "../scripts/interpreter";
import { initialMagikarpRecord, type MagikarpRecord } from "../scripts/lake-of-rage";
import { IntroRun } from "../scripts/intro";
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen";
@@ -198,6 +199,8 @@ 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 };
/** The Magikarp length record and its holder. */
magikarpRecord: MagikarpRecord;
/** wRoamMon1/2/3 (Raikou, Entei, Suicune). */
roamMons: RoamMon[];
/** wRoamMons_CurMapGroup/Number, kept from the last UpdateRoamMons or
@@ -4547,6 +4550,7 @@ export class GameWorld {
firstUnownSeen: state.firstUnownSeen,
timers: structuredClone(state.timers),
luckyNumber: { ...state.luckyNumber },
magikarpRecord: { ...state.magikarpRecord },
roamMons: structuredClone(state.roamMons),
roamMonsMap: state.roamMonsMap,
options: { ...state.options },
@@ -4703,6 +4707,11 @@ export class GameWorld {
!save.luckyNumber ||
!integer(save.luckyNumber.id, 0, 0xffff) ||
!integer(save.luckyNumber.day, 0, RTC_DAYS) ||
!save.magikarpRecord ||
!integer(save.magikarpRecord.feet, 0, 0xff) ||
!integer(save.magikarpRecord.inches, 0, 0xff) ||
typeof save.magikarpRecord.holder !== "string" ||
save.magikarpRecord.holder.length > 10 ||
!Array.isArray(save.roamMons) ||
save.roamMons.length !== 3 ||
!save.roamMons.every(validRoamMon) ||
@@ -4802,6 +4811,7 @@ export class GameWorld {
firstUnownSeen: save.firstUnownSeen,
timers: structuredClone(save.timers),
luckyNumber: { ...save.luckyNumber },
magikarpRecord: { ...save.magikarpRecord },
roamMons: structuredClone(save.roamMons),
roamMonsMap: save.roamMonsMap,
options: { ...save.options },
@@ -4958,6 +4968,9 @@ export function migrateSave(
timers: {},
luckyNumber: { id: 0, day: 0 },
}),
...(!Object.hasOwn(save, "magikarpRecord") && {
magikarpRecord: initialMagikarpRecord(),
}),
...(!Object.hasOwn(save, "roamMons") && {
roamMons: emptyRoamMons(),
roamMonsMap: null,
@@ -5021,6 +5034,7 @@ export function migrateSave(
firstUnownSeen: 0,
timers: {},
luckyNumber: { id: 0, day: 0 },
magikarpRecord: initialMagikarpRecord(),
roamMons: emptyRoamMons(),
roamMonsMap: null,
intro: false,