Add the TM/HM pocket, Egg screens and Violet's remaining scripts
Checks / web (push) Canceled after 0s

The pack's TM/HM pocket lists TMs and HMs with their moves, gives TMs and teaches either through the ABLE/NOT ABLE party menu, the compatibility and "already knows" checks and LearnMove; a TM is used up and makes the Pokémon happier. Eggs show no level or HP in the party menus, get the Egg stats page with its hatching hint, and are skipped by HealParty. Bug Catcher Wade's number registers (VAR_CALLERID and RandomPhoneMon).

The playthrough now runs through Falkner's ZEPHYRBADGE, the Togepi Egg, Kyle's Onix, Flash in Dark Cave, Wade's number and Earl's blackboard. With MAKE_TEST_SAVES it writes a Violet City backup for the manual checks.

Validated with 610 unit tests, 157 browser tests (including all 256 badge bytes against the original BadgeStatBoosts), native/reference builds and reference/vendor/release checks. Egg hatching (#29) and trainer calls (#30) remain on their cards.

Closes #31

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-03 16:49:02 +02:00
1 parent 7909302db1
commit 73022b1505
13 files changed
+803 -31

No files matched your search

+2
View File
@@ -332,6 +332,7 @@ export const C = {
NUM_MONMENU_ITEMS: 8,
PARTYMENUACTION_CHOOSE_POKEMON: 0,
PARTYMENUACTION_HEALING_ITEM: 1,
PARTYMENUACTION_TEACH_TMHM: 3,
PARTYMENUACTION_MOVE: 4,
PARTYMENUACTION_GIVE_ITEM: 8,
KEY_ITEM_POCKET: 2,
@@ -349,6 +350,7 @@ export const C = {
PARTYMENUACTION_EVO_STONE: 5,
PARTYMENUTEXT_LEVEL_UP: 0xf8,
HAPPINESS_USEDITEM: 0x02,
HAPPINESS_LEARNMOVE: 0x05,
// Evolution (constants/pokemon_data_constants.asm)
EVOLVE_LEVEL: 1,
EVOLVE_ITEM: 2,
+13
View File
@@ -399,6 +399,19 @@ export class PokemonData {
const bank = r.byte(SYMBOLS.picsBanks + r.byte(entry) - (pics1 - 0x36));
return decompress(r, r.address(bank, r.word(entry + 1)), 0x2000);
}
/** EggPic (5x5 tiles) in the 7x7 box, as GetFrontpic loads it for EGG,
* with PokemonPalettes' EGG entry. */
eggPic(): Uint8Array {
const tiles = decompress(this.reader, SYMBOLS.eggPic, 0x2000);
if (tiles.length < 25 * 16) throw new Error("Picture data is truncated.");
const padded = new Uint8Array(49 * 16);
for (let column = 0; column < 5; column++)
padded.set(tiles.slice(column * 80, (column + 1) * 80), ((column + 1) * 7 + 2) * 16);
return padded;
}
eggPalette(): Palette {
return this.middleColors(SYMBOLS.pokemonPalettes + 0xfd * 8);
}
/** Front picture tiles, padded into the 7x7 box as PadFrontpic does:
* column-major, bottom-aligned, with a blank left column for 5 and 6. */
frontPic(species: number): Uint8Array {
+17
View File
@@ -59,6 +59,7 @@ export const SYMBOLS = {
caughtAskNicknameText: 0x4db44,
stringEgg: 0xe035,
tmhmMoves: 0x1167a,
eggPic: 0x151caf,
nicknameStrings: 0x11780,
rivalNameString: 0x117c3,
rivalDefaultName: 0xc2b2,
@@ -129,6 +130,7 @@ export const SPECIAL_ROUTINES = {
reloadSpritesNoPalettes: 0xd91,
healParty: 0xc658,
healMachineAnim: 0x12324,
randomPhoneMon: 0x2a567,
checkPokerus: 0xc419,
stubbedTrainerRankingsHealings: 0x1060a2,
warpToSpawnPoint: 0x97c28,
@@ -423,6 +425,12 @@ export const STATS = {
"LoadGreenPage.Move": 0x4e1a9,
IDNoString: 0x4e21e,
OTString: 0x4e222,
EggString: 0x4e3c0,
FiveQMarkString: 0x4e3c4,
EggSoonString: 0x4e3ca,
EggCloseString: 0x4e3fd,
EggMoreTimeString: 0x4e43d,
EggALotMoreTimeString: 0x4e46e,
TypeNames: 0x5097b,
} as const;
@@ -512,6 +520,15 @@ export const PC = {
* outside battle: menu data, strings and texts, keyed by their PRET labels
* so that \`npm run check:reference\` can verify each address by name. */
export const MENU = {
"Pack.MenuData_1": 0x1012c,
"Pack.MenuData_2": 0x10143,
BootedTMText: 0x2c8bf,
BootedHMText: 0x2c8c4,
ContainedMoveText: 0x2c8c9,
TMHMNotCompatibleText: 0x2c8ce,
"KnowsMove.KnowsMoveText": 0xfa06,
"PlacePartyMonTMHMCompatibility.string_able": 0x50221,
"PlacePartyMonTMHMCompatibility.string_not_able": 0x50226,
"StartMenu.Items": 0x126eb,
PartyMenuStrings: 0x504d2,
MonMenuOptionStrings: 0x24caf,
+27 -1
View File
@@ -275,6 +275,8 @@ const UP = 1;
const ROUTE_36 = "10:3";
/** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */
const CONTACT_LIST_SIZE = 10;
/** VAR_CALLERID: wCurCaller, the phone contact a trainer script names. */
const VAR_CALLERID = 0x17;
/** PermanentNumbers: PHONECONTACT_MOM and PHONECONTACT_ELM, which share
* PHONE_MOM's and PHONE_ELM's values. */
const PERMANENT_NUMBERS = [1, 4];
@@ -607,6 +609,8 @@ export function trainerWalkBuffer(
* made to a private draft that the caller commits only after the script ends.
* Unknown commands abort the whole interaction. */
export class ScriptRun {
/** wCurCaller, which loadvar VAR_CALLERID sets for phone scripts. */
private curCaller = 0;
readonly state: ScriptState;
lastTalked: number;
/** Set by sdefer: a script to start after this one returns. */
@@ -1134,6 +1138,10 @@ export class ScriptRun {
this.setPlayerState(setting, offset, opcode);
break;
}
if (variable === VAR_CALLERID) {
this.curCaller = setting;
break;
}
if (variable !== 3 || !SCRIPT_BATTLE_TYPES.includes(setting))
throw new UnsupportedScript(
offset,
@@ -2555,6 +2563,8 @@ export class ScriptRun {
}
case 0x08:
return this.state.playerState;
case VAR_CALLERID:
return this.curCaller;
case 0x0f:
return this.host.environment();
case 0x05:
@@ -2647,9 +2657,25 @@ export class ScriptRun {
// Graphics, palettes and sound only: wPartyCount/wScriptVar select
// an animation sequence but nothing persists past it.
return value;
case SPECIAL_ROUTINES.randomPhoneMon: {
// RandomPhoneMon: GetCallerLocation's trainer, then a party slot
// drawn as Random & 7 until it exists, named into wStringBuffer4.
const entry = PC.PhoneContacts + this.curCaller * PHONE_CONTACT_SIZE;
const party = new TrainerData(r).trainer(r.byte(entry), r.byte(entry + 1)).party;
let slot: number;
let tries = 0;
do {
if (++tries > 4096) throw new Error("Random draw limit exceeded.");
slot = this.host.random() & 7;
} while (slot >= party.length);
this.strings[RAM.stringBuffer4] = this.host.data.speciesName(party[slot].species);
return value;
}
case SPECIAL_ROUTINES.healParty:
// HealPartyMon: clear status, restore HP and every move's PP.
// HealPartyMon for each party member but Eggs: clear status,
// restore HP and every move's PP.
for (const mon of this.state.party) {
if (mon.egg) continue;
mon.status = 0;
mon.hp = mon.stats[0];
mon.pp = mon.moves.map((move, slot) =>
+89 -4
View File
@@ -7,7 +7,8 @@ import {
restoreHealth,
restoredPP,
} from "../battle/battle";
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
import { receiveItem, removeFromSlot, tossItem, type ItemSlot } from "../party/bag";
import { HM01, NUM_TMS, tmhmItem, tmhmNumber } from "../rom/pokemon";
import { calcStats, maxPP, partyMonDetails, type PartyMon } from "../party/pokemon";
import type { GameTime } from "../world/game-time";
import { PartyEvolution, evoStoneCompatible } from "./evolution";
@@ -75,6 +76,7 @@ const {
PARTYMENUACTION_HEALING_ITEM,
PARTYMENUACTION_MOVE,
PARTYMENUACTION_GIVE_ITEM,
PARTYMENUACTION_TEACH_TMHM,
KEY_ITEM_POCKET,
TM_HM_POCKET,
PP_UP,
@@ -729,6 +731,10 @@ export class StartMenuRun {
yield { kind: "mail", mail: { ...mail } };
}
private giveItemToPokemon(pocket: PocketIndex, index: number, item: number) {
if (pocket === TM_HM_POCKET) {
if (!tossItem(this.state.bag, item, 1)) throw new Error("The item is not in the pack.");
return;
}
const list = this.pocketSlots(pocket);
if (!list || !removeFromSlot(list, index, item, 1))
throw new Error("The item is not in the pack.");
@@ -830,10 +836,25 @@ export class StartMenuRun {
const bag = this.state.bag;
return pocket === 0 ? bag.items : pocket === 1 ? bag.balls : undefined;
}
/** A pocket's list: quantities for items and balls, none for key items,
* and no TMs or HMs yet. */
/** A pocket's list: quantities for items and balls, none for key items;
* TMs (with a count) and HMs by number, with the move they teach
* (TMHM_DisplayPocketItems). */
private pocketEntries(pocket: PocketIndex): MartEntry[] {
const bag = this.state.bag;
if (pocket === TM_HM_POCKET)
return bag.tmsHms.flatMap((count, index) => {
if (!count) return [];
const number = index + 1,
item = tmhmItem(number);
return [
{
item,
name: `${this.data.itemName(item)} ${this.data.moveName(this.data.tmhmMove(number))}`,
price: 0,
...(number <= NUM_TMS && { owned: count }),
},
];
});
const slots: ItemSlot[] =
pocket === KEY_ITEM_POCKET
? bag.keyItems.map((item) => ({ item, quantity: 1 }))
@@ -934,11 +955,75 @@ export class StartMenuRun {
for (;;) {
const chosen = yield* this.choosePackItem(true);
if (!chosen) return false;
if (yield* this.itemSubmenu(chosen.pocket, chosen.index, chosen.item))
if (chosen.pocket === TM_HM_POCKET) yield* this.tmhmSubmenu(chosen.item);
else if (yield* this.itemSubmenu(chosen.pocket, chosen.index, chosen.item))
return true;
}
}
/** Pack's .TMHMPocketMenu: USE, GIVE and QUIT for a TM that can be
* tossed; USE and QUIT for an HM. */
private *tmhmSubmenu(item: number): Gen {
const tossable = !this.data.itemUnsellable(item);
const actions = tossable ? ["use", "give", "quit"] : ["use", "quit"];
const choice = yield* this.menu(
[this.data.itemName(item)],
this.stringList(tossable ? MENU["Pack.MenuData_2"] + 2 : MENU["Pack.MenuData_1"] + 2, actions.length),
);
const action = choice < 0 ? "quit" : actions[choice];
if (action === "give") return yield* this.giveItem(TM_HM_POCKET, 0, item);
if (action === "use") yield* this.teachTMHM(item);
}
/** AskTeachTMHM, ChooseMonToLearnTMHM and TeachTMHM: the move goes to a
* compatible Pokémon that does not know it, through LearnMove. A TM is
* used up and makes the Pokémon happier; an HM stays. */
private *teachTMHM(item: number): Gen {
const data = this.data,
number = tmhmNumber(item),
move = data.tmhmMove(number),
name = data.moveName(move);
// GetMoveName and CopyName1.
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = name;
yield* this.print(item >= HM01 ? MENU.BootedHMText : MENU.BootedTMText);
if (!(yield* this.yesNo(MENU.ContainedMoveText))) return;
const party = this.state.party;
let slot: number;
for (;;) {
// PlacePartyMonTMHMCompatibility: ABLE or NOT ABLE; nothing for Eggs.
const strings = MENU.PartyMenuStrings + PARTYMENUACTION_TEACH_TMHM * 2;
const prompt = this.string(this.reader.near(strings, this.reader.bank(strings)));
slot = yield* this.menu(
[prompt],
party.map((mon) => mon.nickname),
party.map((mon) =>
mon.egg
? ""
: this.string(
data.canLearnTMHM(mon.species, move)
? MENU["PlacePartyMonTMHMCompatibility.string_able"]
: MENU["PlacePartyMonTMHMCompatibility.string_not_able"],
),
),
);
if (slot < 0) return;
// An Egg plays SFX_WRONG and the menu stays.
if (!party[slot].egg) break;
}
const mon = party[slot];
this.strings[RAM.stringBuffer1] = mon.nickname;
this.strings[RAM.stringBuffer2] = name;
if (!data.canLearnTMHM(mon.species, move))
return yield* this.print(MENU.TMHMNotCompatibleText);
if (mon.moves.includes(move)) return yield* this.print(MENU["KnowsMove.KnowsMoveText"]);
const evolution = this.evolution();
evolution.strings[RAM.stringBuffer1] = evolution.strings[RAM.stringBuffer2] = name;
if (!(yield* evolution.learnMove(slot, move))) return;
if (item >= HM01) return;
this.changeHappiness(slot, C.HAPPINESS_LEARNMOVE);
// ConsumeTM
tossItem(this.state.bag, item, 1);
}
/** Pack.ItemBallsKey_LoadSubmenu: the submenu the item's permissions and
* field menu select. */
private *itemSubmenu(
+41 -3
View File
@@ -928,9 +928,12 @@ function showBattle(world: GameWorld) {
showWorld(world);
},
{
detail: `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${
mon.status ? ` ${statusName(mon.status)}` : ""
}`,
// An Egg's row has no level or HP.
detail: mon.egg
? ""
: `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${
mon.status ? ` ${statusName(mon.status)}` : ""
}`,
wide: true,
},
),
@@ -1225,6 +1228,41 @@ function showStatsScreen(world: GameWorld) {
const shiny = PokemonData.shiny(mon.dvs);
const text = (label: Parameters<PokemonData["statsString"]>[0]) =>
data.statsString(label);
if (mon.egg) {
// EggStatsScreen: the Egg's picture, "EGG", unknown ID and OT, and a
// hint picked by the egg cycles left.
drawPic(get("stats-pic"), data.eggPic(), 7, data.eggPalette());
get("stats-pic").setAttribute("aria-label", text("EggString"));
const strong = document.createElement("strong");
strong.textContent = text("EggString");
const unknown = text("FiveQMarkString");
const row = (...parts: (string | Node)[]) => {
const p = document.createElement("p");
p.append(...parts);
return p;
};
get("stats-header").replaceChildren(
row(strong),
row(`${text("IDNoString")}${unknown}`),
row(`${text("OTString")}${unknown}`),
);
const cycles = mon.happiness;
get("stats-page").replaceChildren(
row(
text(
cycles < 6
? "EggSoonString"
: cycles < 0xb
? "EggCloseString"
: cycles < 0x29
? "EggMoreTimeString"
: "EggALotMoreTimeString",
).replace(/\n/g, " "),
),
);
panel.dataset.page = "egg";
return;
}
drawPic(
get("stats-pic"),
data.frontPic(mon.species),