Open Route 32 to Route 34, the Ruins of Alph and Azalea Town (#32)

Enable Route 32 and its Pokémon Center, the Ruins of Alph with the four
chambers, item and word rooms, inner chamber and research center, Union
Cave, Route 33, Azalea Town with Kurt's house, the kiln and Bugsy's gym,
the Slowpoke Well, Ilex Forest and Route 34.

Port what their scripts need: saved WRAM bytes by label (readmem,
writemem, loadmem: wFarfetchdPosition, wKurtApricornQuantity),
VAR_UNOWNCOUNT and VAR_KURT_APRICORNS, SelectApricornForKurt and
verbosegiveitemvar, Unown letters (GetUnownLetter, per-letter pictures,
CheckUnownLetter's redraw, UpdateUnownDex, wFirstUnownSeen), the chamber
puzzles (_UnownPuzzle), DisplayUnownWords, HoOhChamber and
OmanyteChamber.

Minigames and specials that draw a whole screen in tiles now share a
tile screen effect with ROM graphics, palettes and sprites, answered
with buttons; the standard font and textbox frame load for them.

The playthrough continues through Kurt's Slowpoke Well, Bugsy, the rival
and the Farfetch'd to the Route 34 gate; its helpers move to
tests/playthrough-driver.ts. New tests solve the Kabuto puzzle through
the world, catch Unown and make Kurt's balls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-06 15:35:27 +02:00
1 parent 15a31f95e4
commit f4ef244fc7
31 files changed
+2334 -528

No files matched your search

+44 -4
View File
@@ -1,6 +1,6 @@
import { RomReader } from "../rom/reader";
import { BATTLE, RAM, SYMBOLS } from "../rom/profile";
import type { PokemonData } from "../rom/pokemon";
import { BATTLE, JOHTO, RAM, SYMBOLS } from "../rom/profile";
import { PokemonData } from "../rom/pokemon";
import type { Bag } from "../party/bag";
import {
MONS_PER_BOX,
@@ -15,7 +15,9 @@ import {
calcStats,
levelForExp,
maxPP,
updateUnownDex,
type PartyMon,
type UnownRecord,
} from "../party/pokemon";
import { readString, readTextEnd, type TextContext } from "../scripts/text";
import { BattleData } from "./data";
@@ -256,8 +258,33 @@ export interface BattleOptions {
/** wOptions' BATTLE_SCENE clear: battle animations on (the default).
* Some texts only show with them off (_CheckBattleScene). */
battleScene?: boolean;
/** wUnownDex and wFirstUnownSeen, which meeting and catching Unown
* update, and wUnlockedUnowns, the letter sets wild Unown may have. */
unown?: UnownRecord;
unlockedUnowns?: number;
}
/** CheckUnownLetter: whether an Unown with these DVs has a letter in one
* of the sets wUnlockedUnowns has unlocked (UnlockedUnownLetterSets, each
* a list of UNOWN_* letters ending with -1). */
export function unownUnlocked(
data: PokemonData,
unlocked: number,
dvs: number,
): boolean {
const r = data.reader,
letter = PokemonData.unownLetter(dvs),
table = JOHTO.UnlockedUnownLetterSets;
for (let set = 0; set < NUM_UNLOCKED_UNOWN_SETS; set++) {
if (!(unlocked & (1 << set))) continue;
for (let p = r.near(table + set * 2, r.bank(table)); r.byte(p) !== 0xff; p++)
if (r.byte(p) === letter) return true;
}
return false;
}
/** NUM_UNLOCKED_UNOWN_SETS (constants/ram_constants.asm). */
const NUM_UNLOCKED_UNOWN_SETS = 4;
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
const BATTLERESULT_BOX_FULL = 0x80;
const SUB1 = 1,
@@ -780,12 +807,17 @@ export class Battle {
this.enemy.stats = [...this.enemy.mon.stats];
this.enemySpecies = mon.species;
this.options.pokedex.seen.add(mon.species);
// LoadEnemyMonToSwitchTo: a trainer's Unown can be the first seen.
const unown = this.options.unown;
if (mon.species === C.UNOWN && unown && !unown.firstUnownSeen)
unown.firstUnownSeen = PokemonData.unownLetter(mon.dvs);
return;
}
if (!this.options.wild) throw new UnsupportedBattle("Missing encounter.");
const { species, level } = this.options.wild;
if (species === C.UNOWN)
throw new UnsupportedBattle("Unown encounters are not ported yet.");
const unown = this.options.unown;
if (species === C.UNOWN && !unown)
throw new UnsupportedBattle("Missing the Unown Dex.");
const data = this.options.data,
base = data.baseData(species);
// 25% chance of an item: 23% the first, 2% the second. Ho-Oh, Lugia
@@ -805,7 +837,13 @@ export class Battle {
dvs = roamer.dvs;
} else if (this.battleType === C.BATTLETYPE_FORCESHINY) dvs = 0xeaaa;
else dvs = (this.random() << 8) | this.random();
// CheckUnownLetter: a letter outside the unlocked sets draws again.
while (species === C.UNOWN && !unownUnlocked(data, this.options.unlockedUnowns ?? 0, dvs))
dvs = (this.random() << 8) | this.random();
while (species === C.MAGIKARP && this.rerollMagikarp(dvs)) dvs = (this.random() << 8) | this.random();
// InitEnemyWildmon: the first Unown met names wFirstUnownSeen.
if (species === C.UNOWN && !unown!.firstUnownSeen)
unown!.firstUnownSeen = PokemonData.unownLetter(dvs);
const stats = calcStats(data, species, dvs, level);
const moves = this.levelMoves(species, level);
this.enemySpecies = species;
@@ -1782,6 +1820,7 @@ export class Battle {
stats: calcStats(data, species, enemy.dvs, level),
};
this.options.party.push(mon);
if (this.options.unown) updateUnownDex(this.options.unown, mon);
mon.nickname = yield* this.askNickname(name);
this.tossItem("balls", item, slot);
return true;
@@ -1978,6 +2017,7 @@ export class Battle {
};
restorePP(data, mon);
box.unshift(mon);
if (this.options.unown) updateUnownDex(this.options.unown, mon, false);
if (box.length === MONS_PER_BOX) this.battleResult |= BATTLERESULT_BOX_FULL;
mon.nickname = yield* this.askNickname(name);
this.strings[RAM.monOrItemNameBuffer] = mon.nickname;
+21 -1
View File
@@ -1,4 +1,4 @@
import type { PokemonData } from "../rom/pokemon";
import { PokemonData } from "../rom/pokemon";
import type { Mail } from "./mail";
/** A party Pokémon with the fields of the original party_struct. */
@@ -34,6 +34,26 @@ export interface PartyMon {
}
export const PARTY_LENGTH = 6;
/** wUnownDex and wFirstUnownSeen, kept with the game. */
export interface UnownRecord {
unownDex: number[];
firstUnownSeen: number;
}
/** UpdateUnownDex: an Unown's letter (GetUnownLetter) is added to the
* Unown Dex the first time it joins the party or a box. TryAddMonToParty
* also records the first letter seen, when none was. */
export function updateUnownDex(
record: UnownRecord,
mon: { species: number; dvs: number },
party = true,
) {
if (mon.species !== UNOWN_SPECIES) return;
const letter = PokemonData.unownLetter(mon.dvs);
if (!record.unownDex.includes(letter) && record.unownDex.length < 26)
record.unownDex.push(letter);
if (party && !record.firstUnownSeen) record.firstUnownSeen = letter;
}
const UNOWN_SPECIES = 201;
const STATUS_NAMES: [number, string][] = [
[0x08, " PSN"],
+53
View File
@@ -0,0 +1,53 @@
import {
SCREEN_HEIGHT,
SCREEN_WIDTH,
type TileScreen,
} from "../scripts/screen";
import { drawTile } from "./pixels";
/** Draw a tile screen: the background tilemap, then the sprites over it
* (color 0 transparent). `frame` counts video frames for blinking sprites. */
export function drawTileScreen(
canvas: HTMLCanvasElement,
screen: TileScreen,
frame: number,
) {
const width = SCREEN_WIDTH * 8,
height = SCREEN_HEIGHT * 8;
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const context = canvas.getContext("2d")!;
const image = context.createImageData(width, height);
for (let row = 0; row < SCREEN_HEIGHT; row++)
for (let col = 0; col < SCREEN_WIDTH; col++) {
const cell = row * SCREEN_WIDTH + col;
if (screen.map[cell] < 0) continue;
drawTile(
image.data,
width,
height,
screen.tiles,
screen.map[cell],
col * 8,
row * 8,
screen.palettes[screen.attrs[cell]] ?? screen.palettes[0],
);
}
const blinkOff = !(frame & 0x10);
for (const sprite of screen.sprites) {
if (sprite.blink && blinkOff) continue;
drawTile(
image.data,
width,
height,
screen.tiles,
sprite.tile,
sprite.x,
sprite.y,
screen.objPalettes[sprite.palette] ?? screen.objPalettes[0],
{ transparent: true, flipX: sprite.flipX, flipY: sprite.flipY },
);
}
context.putImageData(image, 0, 0);
canvas.setAttribute("aria-label", screen.label);
}
+74
View File
@@ -0,0 +1,74 @@
import type { RomReader } from "./reader";
import { JOHTO } from "./profile";
/** TEXTBOX_FRAME_TILES and NUM_FRAMES (constants/text_constants.asm). */
const TEXTBOX_FRAME_TILES = 6,
NUM_FRAMES = 8;
/** The tiles LoadFrame and _LoadStandardFont fill: '┌' at $79, ' ' at
* $7f and the font from $80. */
export const FRAME_TILE = 0x79,
SPACE_TILE = 0x7f,
FONT_TILE = 0x80;
/** Get1bpp: both bit planes copy the 1bpp row, so set pixels are color 3. */
function oneBpp(target: Uint8Array, tile: number, source: Uint8Array) {
for (let row = 0; row < 8; row++) {
target[tile * 16 + row * 2] = source[row];
target[tile * 16 + row * 2 + 1] = source[row];
}
}
/** vTiles2 $79-$7f and vTiles1 as the text engine leaves them: the
* textbox frame chosen in the options (LoadFrame), the space, and the
* standard font (_LoadStandardFont). Indices are tile numbers. */
export function fontTiles(reader: RomReader, frame: number) {
const tiles = new Uint8Array(0x100 * 16);
const frames = JOHTO.Frames + (frame & (NUM_FRAMES - 1)) * TEXTBOX_FRAME_TILES * 8;
for (let i = 0; i < TEXTBOX_FRAME_TILES; i++)
oneBpp(tiles, FRAME_TILE + i, reader.slice(frames + i * 8, 8));
oneBpp(tiles, SPACE_TILE, reader.slice(JOHTO.TextboxSpaceGFX, 8));
for (let i = 0; i < 128; i++)
oneBpp(tiles, FONT_TILE + i, reader.slice(JOHTO.Font + i * 8, 8));
return tiles;
}
/** The characters these screens print, to their charmap codes
* (constants/charmap.asm). */
const CODES: Record<string, number> = {
" ": 0x7f,
"(": 0x9a,
")": 0x9b,
":": 0x9c,
";": 0x9d,
"[": 0x9e,
"]": 0x9f,
"'": 0xe0,
"-": 0xe3,
"?": 0xe6,
"!": 0xe7,
".": 0xe8,
"&": 0xe9,
"é": 0xea,
"→": 0xeb,
"▷": 0xec,
"▶": 0xed,
"▼": 0xee,
"♂": 0xef,
"¥": 0xf0,
"×": 0xf1,
"/": 0xf3,
",": 0xf4,
"♀": 0xf5,
};
/** Text as tile numbers, one character per tile. */
export function textTiles(text: string): number[] {
return [...text].map((character) => {
const code = character.charCodeAt(0);
if (character >= "A" && character <= "Z") return 0x80 + code - 65;
if (character >= "a" && character <= "z") return 0xa0 + code - 97;
if (character >= "0" && character <= "9") return 0xf6 + code - 48;
const mapped = CODES[character];
if (mapped === undefined) throw new Error(`No tile for "${character}".`);
return mapped;
});
}
+34 -8
View File
@@ -3,6 +3,7 @@ import { decompress } from "./lz";
import {
BATTLE,
INTRO,
JOHTO,
MENU,
NUM_ITEMS,
NUM_POKEMON,
@@ -89,6 +90,10 @@ const STAT_TRIGGERS = ["gt", "lt", "eq"] as const;
/** Decoders for species, moves and items. All data is read from the
* user's ROM on demand and cached in memory only. */
/** UNOWN and NUM_UNOWN (constants/pokemon_constants.asm). */
const UNOWN = 201;
export const NUM_UNOWN = 26;
export class PokemonData {
private base = new Map<number, BaseData>();
private names = new Map<string, string>();
@@ -389,11 +394,33 @@ export class PokemonData {
return ((quadratic & 0xffffff) + cubic) & 0xffffff;
}
/** The decompressed front picture: base frame, then animation tiles. */
frontPicData(species: number): Uint8Array {
/** GetFrontpicPointer's entry: PokemonPicPointers, or for Unown
* UnownPicPointers by letter (1-26), at the same address in the next
* bank. */
private picEntry(species: number, letter: number) {
this.checkSpecies(species);
if (species !== UNOWN) return SYMBOLS.pokemonPics + (species - 1) * 6;
if (!Number.isInteger(letter) || letter < 1 || letter > NUM_UNOWN)
throw new Error("Unknown Unown letter.");
return JOHTO.UnownPicPointers + (letter - 1) * 6;
}
/** GetUnownLetter: the middle two bits of each DV (Attack, Defense,
* Speed, Special) make a byte, divided by $ff / NUM_UNOWN + 1, plus one. */
static unownLetter(dvs: number): number {
const high = dvs >> 8,
low = dvs & 0xff;
const value =
((high & 0x60) << 1) |
((high & 0x06) << 3) |
((low & 0x60) >> 3) |
((low & 0x06) >> 1);
return Math.floor(value / (Math.floor(0xff / NUM_UNOWN) + 1)) + 1;
}
/** The decompressed front picture: base frame, then animation tiles.
* Unown's depends on its letter. */
frontPicData(species: number, letter = 1): Uint8Array {
const r = this.reader,
entry = SYMBOLS.pokemonPics + (species - 1) * 6;
entry = this.picEntry(species, letter);
// FixPicBank: stored banks index a table whose first entry is "Pics 1".
const pics1 = r.byte(SYMBOLS.picsBanks);
const bank = r.byte(SYMBOLS.picsBanks + r.byte(entry) - (pics1 - 0x36));
@@ -414,10 +441,10 @@ export class PokemonData {
}
/** 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 {
frontPic(species: number, letter = 1): Uint8Array {
const size = this.baseData(species).picSize;
if (size < 5 || size > 7) throw new Error("Invalid picture size.");
const tiles = this.frontPicData(species);
const tiles = this.frontPicData(species, letter);
if (tiles.length < size * size * 16)
throw new Error("Picture data is truncated.");
const padded = new Uint8Array(49 * 16);
@@ -430,10 +457,9 @@ export class PokemonData {
return padded;
}
/** The back picture: 6x6 tiles, column by column (GetMonBackpic). */
backPic(species: number): Uint8Array {
this.checkSpecies(species);
backPic(species: number, letter = 1): Uint8Array {
const r = this.reader,
entry = SYMBOLS.pokemonPics + (species - 1) * 6 + 3;
entry = this.picEntry(species, letter) + 3;
const pics1 = r.byte(SYMBOLS.picsBanks);
const bank = r.byte(SYMBOLS.picsBanks + r.byte(entry) - (pics1 - 0x36));
const tiles = decompress(r, r.address(bank, r.word(entry + 1)), 0x1000);
+85
View File
@@ -186,6 +186,8 @@ export const EVENTS = {
playersHouseBigDoll: 1860,
wallOpenedInAerodactylChamber: 809,
wallOpenedInKabutoChamber: 807,
wallOpenedInHoOhChamber: 806,
wallOpenedInOmanyteChamber: 808,
} as const;
/** Engine flag indices named directly by translated routines. */
@@ -1039,6 +1041,53 @@ export const OPTIONS = {
"Options_MenuAccount.On": 0xe44f6,
} as const;
/** Johto's scripts, routines and tables from Route 32 on, keyed by their
* PRET labels for `npm run check:reference`. */
export const JOHTO = {
UnlockedUnownLetterSets: 0x3eba1,
UnownPicPointers: 0x124000,
"InitUnownPuzzlePiecePositions.PuzzlePieceInitialPositions": 0xe126d,
"GetCurrentPuzzlePieceVTileCorner.Corners": 0xe148f,
"CheckSolvedUnownPuzzle.SolvedPuzzleConfiguration": 0xe14b5,
"RedrawUnownPuzzlePieces.OAM_HoldingPiece": 0xe150f,
"RedrawUnownPuzzlePieces.OAM_NotHoldingPiece": 0xe1534,
UnownPuzzleCoordData: 0xe1559,
"PuzzlePieceBorderData.TileBordersGFX": 0xe1723,
UnownPuzzleCursorGFX: 0xe17c5,
UnownPuzzleStartCancelLZ: 0xe1805,
HoOhPuzzleLZ: 0xe18ab,
AerodactylPuzzleLZ: 0xe19fb,
KabutoPuzzleLZ: 0xe1bab,
OmanytePuzzleLZ: 0xe1c9b,
Font: 0xf8200,
Frames: 0xf8800,
TextboxSpaceGFX: 0xf9204,
UnownWalls: 0x8aebc,
MenuHeaders_UnownWalls: 0x8aed5,
UnownPuzzle: 0xc360,
DisplayUnownWords: 0x8ae68,
HoOhChamber: 0x8addb,
OmanyteChamber: 0x8adef,
ApricornBalls: 0x24ca0,
SelectApricornForKurt: 0x88018,
"Kurt_PrintTextWhichApricorn.WhichApricornText": 0x88007,
"Kurt_PrintTextHowMany.HowManyShouldIMakeText": 0x88013,
} as const;
/** Saved WRAM bytes that map scripts read and write directly (readmem,
* writemem, loadmem and readvar), keyed by their PRET labels; the native
* save keeps them by label in `memory`. */
export const SAVED_WRAM = {
wFarfetchdPosition: 0xd964,
wKurtApricornQuantity: 0xdca4,
} as const;
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 = {
wTilemap: 0xc4a0,
} as const;
export const MAP_GROUP_COUNT = 26;
export const NUM_POKEMON = 251;
export const NUM_TRAINER_CLASSES = 67;
@@ -1083,5 +1132,41 @@ export const NATIVE_MAPS: Record<string, string> = {
"3:1": "Sprout Tower 1F",
"3:2": "Sprout Tower 2F",
"3:3": "Sprout Tower 3F",
"10:1": "Route 32",
"10:12": "Ruins of Alph gate",
"10:13": "Pokémon Center",
"10:16": "Ruins of Alph gate",
"3:22": "Ruins of Alph",
"3:23": "Ho-Oh chamber",
"3:24": "Kabuto chamber",
"3:25": "Omanyte chamber",
"3:26": "Aerodactyl chamber",
"3:27": "Inner chamber",
"3:28": "Research center",
"3:29": "Ho-Oh item room",
"3:30": "Kabuto item room",
"3:31": "Omanyte item room",
"3:32": "Aerodactyl item room",
"3:33": "Ho-Oh word room",
"3:34": "Kabuto word room",
"3:35": "Omanyte word room",
"3:36": "Aerodactyl word room",
"3:37": "Union Cave 1F",
"3:38": "Union Cave B1F",
"3:39": "Union Cave B2F",
"8:6": "Route 33",
"8:7": "Azalea Town",
"8:1": "Pokémon Center",
"8:2": "Charcoal kiln",
"8:3": "Azalea Mart",
"8:4": "Kurt’s house",
"8:5": "Azalea Gym",
"3:40": "Slowpoke Well B1F",
"3:41": "Slowpoke Well B2F",
"11:22": "Ilex Forest gate",
"3:52": "Ilex Forest",
"11:23": "Route 34 gate",
"11:1": "Route 34",
"11:24": "Day-Care",
};
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
+173 -5
View File
@@ -7,6 +7,7 @@ import { RomReader } from "../rom/reader";
import {
EMPTY_ROUTINES,
ENGINE_FLAGS,
JOHTO,
MENU,
NATIVE_ROUTINES,
NUM_FRUIT_TREES,
@@ -16,8 +17,10 @@ import {
PC,
POKEGEAR,
RAM,
SAVED_WRAM,
SPECIAL_ROUTINES,
SYMBOLS,
type SavedByte,
} from "../rom/profile";
import { PokemonData } from "../rom/pokemon";
import { C } from "../battle/constants";
@@ -46,6 +49,7 @@ import {
generatePartyMon,
maxPP,
partyMonDetails,
updateUnownDex,
type PartyMon,
} from "../party/pokemon";
import { copyMail, type Mail } from "../party/mail";
@@ -57,6 +61,10 @@ import { PcSession } from "./pc";
import { bankOfMom, type MomBankView } from "./mom";
import type { PokegearView } from "./pokegear";
import type { FlyMapView } from "./fly-map";
import type { TileScreen } from "./screen";
import { unownPuzzle } from "./unown-puzzle";
import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins";
import type { Palette } from "../world/types";
import { rockMonEncounter, treeMonEncounter } from "./treemons";
import type { TrainerCardView } from "./trainer-card";
import type { PokedexView } from "./pokedex";
@@ -144,6 +152,13 @@ export interface ScriptState {
tradeFlags: number;
/** wBlueCardBalance: the Blue Card's points. */
blueCardBalance: number;
/** Saved WRAM bytes that scripts read and write directly, by label
* (SAVED_WRAM); an absent byte is 0, as in a new game. */
memory: Partial<Record<SavedByte, number>>;
/** wUnownDex: the Unown letters (1-26) in the order first caught, and
* wFirstUnownSeen, the letter of the first Unown met in battle. */
unownDex: number[];
firstUnownSeen: number;
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
options: GameOptions;
/** wParkBallsRemaining and wContestMon while the Bug-Catching Contest
@@ -184,6 +199,9 @@ export const emptyScriptState = (): ScriptState => ({
coins: 0,
tradeFlags: 0,
blueCardBalance: 0,
memory: {},
unownDex: [],
firstUnownSeen: 0,
options: defaultOptions(),
});
export const copyScriptState = (state: ScriptState): ScriptState => ({
@@ -222,6 +240,9 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
coins: state.coins,
tradeFlags: state.tradeFlags,
blueCardBalance: state.blueCardBalance,
memory: { ...state.memory },
unownDex: [...state.unownDex],
firstUnownSeen: state.firstUnownSeen,
options: { ...state.options },
...(state.bugContest && {
bugContest: {
@@ -308,7 +329,14 @@ const POKEMAIL_WRONG_MAIL = 0,
/** One row of a mart's buy or sell list. `owned` is set only when selling. */
/** The mart's lists and quantity box, which the PC's item storage reuses. */
export type MartMode = "buy" | "sell" | "withdraw" | "deposit" | "toss";
export type MartMode =
| "buy"
| "sell"
| "withdraw"
| "deposit"
| "toss"
/** A quantity for a script, such as Kurt's apricorns. */
| "quantity";
export interface MartEntry {
item: number;
name: string;
@@ -478,7 +506,10 @@ export type ScriptEffect =
| { kind: "mapblocks"; address: number }
/** writecmdqueue with CMDQUEUE_STONETABLE: the map's stone table
* (warp, object, script) that boulders falling into pits trigger. */
| { kind: "stonetable"; address: number };
| { kind: "stonetable"; address: number }
/** A full-screen minigame or special drawn in tiles, answered with the
* button pressed (SCREEN_BUTTONS). */
| { kind: "screen"; screen: TileScreen };
/** What the world supplies about the present moment. */
export interface ScriptHost {
@@ -512,6 +543,9 @@ export interface ScriptHost {
/** GetFacingObject: the visible object on the facing tile, with its
* MAPOBJECT_MOVEMENT. */
facingObject(): { id: number; movement: number } | undefined;
/** The map's tileset graphics and background palettes as shown now,
* for specials that draw over the map. */
tileset?(): { graphics: Uint8Array; palettes: Palette[] };
}
export class UnsupportedScript extends Error {
@@ -527,6 +561,11 @@ export class UnsupportedScript extends Error {
}
type Input = number | string | undefined;
/** The SAVED_WRAM label at a RAM address, if it is one. */
const savedByte = (address: number) =>
(Object.keys(SAVED_WRAM) as SavedByte[]).find(
(label) => SAVED_WRAM[label] === address,
);
/** ITEM_FROM_MEM: take the item from wScriptVar. */
const ITEM_FROM_MEM = 0xff;
/** wCallerContact's script-2 pointer (bank, address) within the contact. */
@@ -1107,15 +1146,29 @@ export class ScriptRun {
value = tempWild?.species ?? 0;
else if (address === RAM.parkBallsRemaining && this.state.bugContest)
value = this.state.bugContest.parkBalls;
else if (savedByte(address))
value = this.state.memory[savedByte(address)!] ?? 0;
else throw new UnsupportedScript(offset, opcode);
break;
}
case 0x1a: {
// writemem: wScriptVar into a saved byte.
const label = savedByte(word());
if (!label) throw new UnsupportedScript(offset, opcode);
this.state.memory[label] = value;
break;
}
case 0x1b: {
// loadmem: the trainer script's wRunningTrainerBattleScript, and
// hBGMapMode, which only affects how the screen is redrawn.
const address = word(),
stored = byte();
if (address === RAM.bgMapMode) break;
const label = savedByte(address);
if (label) {
this.state.memory[label] = stored;
break;
}
if (address !== RAM.runningTrainerBattleScript)
throw new UnsupportedScript(offset, opcode);
temp(offset, opcode);
@@ -1480,14 +1533,16 @@ export class ScriptRun {
value = Number(this.state.specialPhoneCall !== 0);
break;
case 0x1f:
case 0x9e: {
case 0x9e:
case 0x9f: {
// verbosegiveitemvar takes the quantity from a script variable.
const item = byte(),
quantity = byte();
quantity = opcode === 0x9f ? this.readVar(byte(), offset) : byte();
this.currentItem = item === ITEM_FROM_MEM ? value : item;
value = Number(
receiveItem(data, this.state.bag, this.currentItem, quantity),
);
if (opcode === 0x9e) {
if (opcode !== 0x1f) {
// verbosegiveitem names the item, then calls the ROM's own
// GiveItemScript for its text and pocket message.
const name = data.itemName(this.currentItem);
@@ -1559,6 +1614,7 @@ export class ScriptRun {
),
});
this.state.party.push(mon);
updateUnownDex(this.state, mon);
this.state.pokedex.seen.add(species);
this.state.pokedex.caught.add(species);
// GiveANickname_YesNo reads the species name from wStringBuffer1.
@@ -2323,6 +2379,76 @@ export class ScriptRun {
}
}
/** SelectApricornForKurt (engine/events/kurt.asm): the apricorns in the
* Items pocket (FindApricornsInBag, in ApricornBalls' order) with their
* counts, then how many (BuySellToss_InterpretJoypad's 1 to the count,
* at most 99). B on the quantity returns to the list. The apricorns are
* taken and the count kept in wKurtApricornQuantity. Returns the
* apricorn, or 0 when none was chosen. */
private *selectApricornForKurt(
pages: string[],
take: () => string[],
): Generator<ScriptEffect, number, Input> {
const r = this.reader,
data = this.host.data,
bag = this.state.bag;
const write = (address: number) => {
const content = readText(r, address, this.text());
if (content) pages.push(...content.split("\n\n"));
};
this.state.memory.wKurtApricornQuantity = 0;
// Kurt_GetQuantityOfApricorn: every stack, at most 99.
const count = (item: number) =>
Math.min(
99,
bag.items
.filter((slot) => slot.item === item)
.reduce((sum, slot) => sum + slot.quantity, 0),
);
let selected = 0;
for (;;) {
write(JOHTO["Kurt_PrintTextWhichApricorn.WhichApricornText"]);
const apricorns: number[] = [];
for (let p = JOHTO.ApricornBalls; r.byte(p) !== 0xff; p += 2)
if (count(r.byte(p))) apricorns.push(r.byte(p));
if (!apricorns.length) {
take();
return 0;
}
const answer = yield {
kind: "menu",
pages: take(),
items: [...apricorns.map((item) => data.itemName(item)), "CANCEL"],
details: [
...apricorns.map((item) => `×${String(count(item)).padStart(2, " ")}`),
"",
],
selected,
};
if (typeof answer !== "number" || !Number.isInteger(answer))
throw new Error("Invalid apricorn.");
if (answer < 0 || answer >= apricorns.length) return 0;
selected = answer;
const item = apricorns[answer];
write(JOHTO["Kurt_PrintTextHowMany.HowManyShouldIMakeText"]);
const chosen = yield {
kind: "martquantity",
mode: "quantity",
item,
name: data.itemName(item),
price: 0,
max: count(item),
prompt: take().join("\n"),
selected: 1,
};
if (typeof chosen !== "number" || chosen < 1) continue;
if (chosen > count(item)) throw new Error("Invalid apricorn count.");
this.state.memory.wKurtApricornQuantity = chosen;
tossItem(bag, item, chosen);
return item;
}
}
/** VAR_MOVEMENT: only the states GetPlayerSprite knows. */
private setPlayerState(state: number, offset: number, opcode: number) {
if (![0, 1, 2, 4, 8].includes(state))
@@ -2592,6 +2718,11 @@ export class ScriptRun {
return this.host.position().y;
case 0x14:
return this.state.specialPhoneCall;
case 0x0e:
// .UnownCaught: the letters in wUnownDex.
return this.state.unownDex.length;
case 0x16:
return this.state.memory.wKurtApricornQuantity ?? 0;
default:
throw new UnsupportedScript(offset, 0x1c, ` (variable ${id})`);
}
@@ -2747,6 +2878,43 @@ export class ScriptRun {
case OVERWORLD.OverworldTownMap:
yield { kind: "townmap" };
return value;
case JOHTO.UnownPuzzle: {
// FadeToMenu: the text box closes first.
if (pages.length) yield { kind: "text", pages: take() };
const solved = yield* unownPuzzle(
{ reader: r, random: () => this.host.random() },
value,
);
return Number(solved);
}
case JOHTO.DisplayUnownWords: {
const tileset = this.host.tileset;
if (!tileset) return undefined;
// The wall's text stays on screen under the box.
if (pages.length) yield { kind: "text", pages: take() };
yield* displayUnownWords(
{
reader: r,
tileset: () => tileset(),
frame: this.state.options.textboxFrame,
},
value,
);
return value;
}
case JOHTO.SelectApricornForKurt:
return yield* this.selectApricornForKurt(pages, take);
case JOHTO.HoOhChamber:
if (hoOhChamber(this.state.party))
this.state.events.add(EVENTS.wallOpenedInHoOhChamber);
return value;
case JOHTO.OmanyteChamber:
if (
!this.state.events.has(EVENTS.wallOpenedInOmanyteChamber) &&
omanyteChamber(this.state.bag.items, this.state.party)
)
this.state.events.add(EVENTS.wallOpenedInOmanyteChamber);
return value;
case MOM.BankOfMom:
yield* bankOfMom(
{
+5
View File
@@ -53,6 +53,8 @@ export interface PokedexView {
entry?: PokedexEntryView;
/** String_SEEN and String_OWN from the ROM. */
labels: { seen: string; own: string };
/** Pokedex_LoadSelectedMonTiles: Unown shows wFirstUnownSeen's letter. */
unownLetter: number;
}
/** DisplayDexEntry: the number, name and category always; the height,
@@ -82,6 +84,8 @@ export interface PokedexHost {
dex: PokedexData;
seen: ReadonlySet<number>;
caught: ReadonlySet<number>;
/** wFirstUnownSeen, the letter Unown's picture shows (0 before any). */
firstUnownSeen?: number;
}
/** Pokedex (engine/pokedex/pokedex.asm) in New mode: the main list and
@@ -260,6 +264,7 @@ export class Pokedex {
return {
seen: this.host.seen.size,
own: this.host.caught.size,
unownLetter: this.host.firstUnownSeen || 1,
cursor: this.cursor,
scroll: this.scroll,
end: this.end,
+128
View File
@@ -0,0 +1,128 @@
import { FRAME_TILE, SPACE_TILE, fontTiles } from "../rom/font";
import type { RomReader } from "../rom/reader";
import { JOHTO } from "../rom/profile";
import type { PartyMon } from "../party/pokemon";
import type { ItemSlot } from "../party/bag";
import type { Palette } from "../world/types";
import type { ScriptEffect } from "./interpreter";
import { SCREEN_HEIGHT, SCREEN_WIDTH, type TileScreen } from "./screen";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** PAL_BG_BROWN and PAL_BG_TEXT (constants/tileset_constants.asm). */
const PAL_BG_BROWN = 5,
PAL_BG_TEXT = 7;
/** HO_OH, WATER_STONE, and the walls' events (constants). */
const HO_OH = 250,
WATER_STONE = 0x18;
/** The Ruins chambers' tilesets keep Unown letters in VRAM bank 1 (here
* tiles $100 on), with Y, Z and the dash in bank 0. */
const BANK_1 = 0x100;
/** What the Unown words need from the map on screen. */
export interface UnownWordsHost {
reader: RomReader;
/** The map's tileset graphics (bank 0 tiles, then bank 1 from $60) and
* its background palettes at this time of day. */
tileset(): { graphics: Uint8Array; palettes: Palette[] };
/** wTextboxFrame. */
frame: number;
}
/** DisplayUnownWords (engine/events/unown_walls.asm): MenuHeaders_UnownWalls'
* box for UNOWNWORDS_* `word`, with the word from UnownWalls in Unown
* letters (2 x 2 tiles each, brown), until A or B. */
export function* displayUnownWords(host: UnownWordsHost, word: number): Gen {
const r = host.reader,
{ graphics, palettes } = host.tileset();
// The word: -1-terminated strings in the unown charmap.
let p = JOHTO.UnownWalls;
for (let skip = word; skip > 0; skip--) while (r.byte(p++) !== 0xff);
const letters: number[] = [];
for (let c = r.byte(p); c !== 0xff; c = r.byte(++p)) letters.push(c);
// menu_coords: flags, top, left, bottom, right.
const header = JOHTO.MenuHeaders_UnownWalls + word * 5;
const top = r.byte(header + 1),
left = r.byte(header + 2),
bottom = r.byte(header + 3),
right = r.byte(header + 4);
const tiles = new Uint8Array(0x200 * 16);
tiles.set(fontTiles(r, host.frame));
tiles.set(graphics.slice(0, 0x60 * 16), 0);
tiles.set(graphics.slice(0x60 * 16, 0xc0 * 16), BANK_1 * 16);
const map = Array<number>(SCREEN_WIDTH * SCREEN_HEIGHT).fill(-1),
attrs = Array<number>(SCREEN_WIDTH * SCREEN_HEIGHT).fill(PAL_BG_TEXT);
const put = (x: number, y: number, tile: number, palette = PAL_BG_TEXT) => {
map[y * SCREEN_WIDTH + x] = tile;
attrs[y * SCREEN_WIDTH + x] = palette;
};
// MenuBox: TextboxBorder's frame ($79-$7e: ┌ ─ ┐ │ └ ┘) around spaces.
for (let y = top; y <= bottom; y++)
for (let x = left; x <= right; x++) {
const side = x === left ? 0 : x === right ? 2 : 1;
put(
x,
y,
y === top
? FRAME_TILE + [0, 1, 2][side]
: y === bottom
? FRAME_TILE + [4, 1, 5][side]
: side === 1
? SPACE_TILE
: FRAME_TILE + 3,
);
}
// _DisplayUnownWords_CopyWord two rows below the box's top: each letter
// c takes tiles c, c+1, c+$10, c+$11 (bank 1); Y, Z and the dash use
// fixed bank 0 tiles.
let x = left + 1;
const y = top + 2;
for (const c of letters) {
// The unown charmap puts letter i at $10 * (i / 8) + 2 * i: Y is
// $60, Z $62 and the dash $64.
const square =
c === 0x60
? [0x5b, 0x5c, 0x4d, 0x5d]
: c === 0x62
? [0x4e, 0x4f, 0x5e, 0x5f]
: c === 0x64
? [0x02, 0x03, 0x03, 0x02]
: [c, c + 1, c + 0x10, c + 0x11].map((tile) => BANK_1 + tile);
put(x, y, square[0], PAL_BG_BROWN);
put(x + 1, y, square[1], PAL_BG_BROWN);
put(x, y + 1, square[2], PAL_BG_BROWN);
put(x + 1, y + 1, square[3], PAL_BG_BROWN);
x += 2;
}
const screen: TileScreen = {
tiles,
map,
attrs,
palettes,
sprites: [],
objPalettes: palettes,
tileset: `unown-words-${word}`,
label: "UNOWN letters on the wall",
overlay: true,
};
// JoyWaitAorB, then CloseWindow.
for (;;) {
const button = yield { kind: "screen", screen };
if (button === "a" || button === "b") return;
}
}
/** HoOhChamber: Ho-Oh first in the party opens the chamber's wall. */
export function hoOhChamber(party: readonly PartyMon[]) {
return party[0] !== undefined && !party[0].egg && party[0].species === HO_OH;
}
/** OmanyteChamber: a WATER STONE in the Items pocket or held by a party
* Pokémon (checked from the last) opens the wall. */
export function omanyteChamber(items: readonly ItemSlot[], party: readonly PartyMon[]) {
return (
items.some((slot) => slot.item === WATER_STONE) ||
party.some((mon) => mon.item === WATER_STONE)
);
}
+104
View File
@@ -0,0 +1,104 @@
import type { RomReader } from "../rom/reader";
import type { Palette } from "../world/types";
/** The buttons a tile screen reads (hJoyPressed and hJoyLast). */
export const SCREEN_BUTTONS: readonly string[] = [
"a",
"b",
"start",
"select",
"up",
"down",
"left",
"right",
];
export const SCREEN_WIDTH = 20,
SCREEN_HEIGHT = 18;
/** One OAM entry in screen pixels (the top-left of its 8x8 tile). */
export interface ScreenSprite {
x: number;
y: number;
tile: number;
palette: number;
flipX?: boolean;
flipY?: boolean;
/** Hidden while bit 4 of the frame counter is clear, as screens that
* blink their cursor with hVBlankCounter do. */
blink?: boolean;
}
/** A full-screen Game Boy picture that a minigame or special draws in
* tiles: the background tilemap with its palettes and the sprites over it,
* all from ROM graphics. It is answered with the button pressed. */
export interface TileScreen {
/** 2bpp graphics; the tilemap and sprites index tiles in it. */
tiles: Uint8Array;
/** SCREEN_WIDTH x SCREEN_HEIGHT tile indices, row by row; -1 leaves
* the game view beneath visible (with `overlay`). */
map: number[];
/** The background palette (0-7) of each tile, row by row. */
attrs: number[];
palettes: Palette[];
sprites: ScreenSprite[];
objPalettes: Palette[];
/** Changes whenever `tiles` does, so presentation can cache them. */
tileset: string;
/** Read aloud with the picture (an accessible description). */
label: string;
/** Drawn over the overworld, like a menu box, rather than replacing it. */
overlay?: boolean;
}
/** A GBC color (RGB555, little-endian word) as RGB. */
export function rgb(value: number): [number, number, number] {
return [
Math.round(((value & 31) * 255) / 31),
Math.round((((value >> 5) & 31) * 255) / 31),
Math.round((((value >> 10) & 31) * 255) / 31),
];
}
/** Four GBC colors (8 bytes) from the ROM. */
export function romPalette(reader: RomReader, address: number): Palette {
return Array.from({ length: 4 }, (_, i) => rgb(reader.word(address + i * 2)));
}
/** An empty screen of tile 0 in palette 0. */
export function blankScreen(
tiles: Uint8Array,
tileset: string,
palettes: Palette[],
label: string,
): TileScreen {
return {
tiles,
map: Array<number>(SCREEN_WIDTH * SCREEN_HEIGHT).fill(0),
attrs: Array<number>(SCREEN_WIDTH * SCREEN_HEIGHT).fill(0),
palettes,
sprites: [],
objPalettes: palettes,
tileset,
label,
};
}
/** Fill a box of the tilemap with one tile (and optionally a palette). */
export function fillBox(
screen: TileScreen,
x: number,
y: number,
width: number,
height: number,
tile: number,
palette?: number,
) {
for (let row = y; row < y + height; row++)
for (let col = x; col < x + width; col++) {
if (col < 0 || row < 0 || col >= SCREEN_WIDTH || row >= SCREEN_HEIGHT)
continue;
screen.map[row * SCREEN_WIDTH + col] = tile;
if (palette !== undefined) screen.attrs[row * SCREEN_WIDTH + col] = palette;
}
}
+1
View File
@@ -337,6 +337,7 @@ export class StartMenuRun {
dex: this.host.dex,
seen: this.state.pokedex.seen,
caught: this.state.pokedex.caught,
firstUnownSeen: this.state.firstUnownSeen,
},
this.cursors.dex,
);
+267
View File
@@ -0,0 +1,267 @@
import { decompress } from "../rom/lz";
import type { RomReader } from "../rom/reader";
import { JOHTO, TRAINER_CARD, WRAM } from "../rom/profile";
import { OW } from "../world/constants";
import type { ScriptEffect } from "./interpreter";
import {
SCREEN_WIDTH,
blankScreen,
fillBox,
romPalette,
rgb,
type ScreenSprite,
type TileScreen,
} from "./screen";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** PUZZLE_BORDER and PUZZLE_VOID, and the tiles the puzzle loads into
* vTiles0: the cursor at $e0 and START/CANCEL's graphics at $ed. */
const PUZZLE_BORDER = 0xee,
PUZZLE_VOID = 0xef,
CURSOR_TILE = 0xe0,
START_CANCEL_TILE = 0xed;
/** The board is 6 x 6 places; pieces are 3 x 3 tiles. */
const PLACES = 6 * 6,
PIECES = 16;
/** UNOWNPUZZLE_* (constants/script_constants.asm), in .LZPointers order. */
const PUZZLE_GRAPHICS = [
JOHTO.KabutoPuzzleLZ,
JOHTO.OmanytePuzzleLZ,
JOHTO.AerodactylPuzzleLZ,
JOHTO.HoOhPuzzleLZ,
] as const;
/** .EnlargedTiles: each bit of a nybble doubled. */
const enlarge = (nybble: number) =>
(nybble & 8) * 0x18 + (nybble & 4) * 0x0c + (nybble & 2) * 0x06 + (nybble & 1) * 0x03;
/** LoadUnownPuzzlePiecesGFX and ConvertLoadedPuzzlePieces: the puzzle's
* 6 x 6 picture drawn at twice its size (12 x 12 tiles from $00), the
* piece borders ORed onto every piece (UnownPuzzle_AddPuzzlePieceBorders),
* then the cursor and START/CANCEL graphics. */
export function unownPuzzleTiles(reader: RomReader, puzzle: number) {
const tiles = new Uint8Array(0x100 * 16);
const picture = decompress(reader, PUZZLE_GRAPHICS[puzzle & 3], 0x1000);
let out = 0;
for (let row = 0; row < 6; row++)
for (let half = 0; half < 2; half++)
for (let column = 0; column < 6; column++) {
const source = (row * 6 + column) * 16 + half * 8;
for (const shift of [4, 0])
for (let line = 0; line < 4; line++) {
const low = enlarge((picture[source + line * 2] >> shift) & 15),
high = enlarge((picture[source + line * 2 + 1] >> shift) & 15);
tiles.set([low, high, low, high], out + line * 4);
if (line === 3) out += 16;
}
}
// PuzzlePieceBorderData: eight border tiles, each ORed onto its tile of
// every piece (four rows of four pieces, three tiles apart).
const borders = [0x00, 0x01, 0x02, 0x0c, 0x0e, 0x18, 0x19, 0x1a];
borders.forEach((offset, border) => {
for (let pieceRow = 0; pieceRow < 4; pieceRow++)
for (let pieceColumn = 0; pieceColumn < 4; pieceColumn++) {
const tile = offset + pieceColumn * 3 + pieceRow * 36;
for (let i = 0; i < 16; i++)
tiles[tile * 16 + i] |= reader.byte(
JOHTO["PuzzlePieceBorderData.TileBordersGFX"] + border * 16 + i,
);
}
});
tiles.set(reader.slice(JOHTO.UnownPuzzleCursorGFX, 4 * 16), CURSOR_TILE * 16);
const startCancel = decompress(reader, JOHTO.UnownPuzzleStartCancelLZ, 0x200);
tiles.set(
startCancel.slice(0, (0x100 - START_CANCEL_TILE) * 16),
START_CANCEL_TILE * 16,
);
return tiles;
}
/** What the puzzle needs from the game. */
export interface UnownPuzzleHost {
reader: RomReader;
/** The Random routine: one byte. */
random(): number;
}
/** The board as the screen shows it, and the button that ends a turn. */
export interface UnownPuzzleState {
/** wPuzzlePieces: the piece (1-16) at each of the 36 places, or 0. */
pieces: number[];
/** wUnownPuzzleCursorPosition. */
cursor: number;
/** wUnownPuzzleHeldPiece while wHoldingUnownPuzzlePiece is set. */
held: number;
}
/** InitUnownPuzzlePiecePositions: pieces 1-16 go to random places on the
* border, drawing again while the place drawn is taken. */
export function initialPieces(host: UnownPuzzleHost): number[] {
const r = host.reader,
pieces = Array<number>(PLACES).fill(0);
for (let piece = 1; piece <= PIECES; piece++)
for (let tries = 0; ; tries++) {
if (tries > 4096) throw new Error("Random draw limit exceeded.");
const place = r.byte(
JOHTO["InitUnownPuzzlePiecePositions.PuzzlePieceInitialPositions"] +
(host.random() & 15),
);
if (pieces[place]) continue;
pieces[place] = piece;
break;
}
return pieces;
}
/** UnownPuzzleJumptable's d-pad: the cursor stays on the board and steps
* over START/CANCEL along the bottom row. Undefined when it cannot move. */
export function moveCursor(cursor: number, direction: string): number | undefined {
const row = Math.floor(cursor / 6),
column = cursor % 6;
switch (direction) {
case "up":
return row ? cursor - 6 : undefined;
case "down":
return row === 5 || (row === 4 && column >= 1 && column <= 4)
? undefined
: cursor + 6;
case "left":
if (!column) return undefined;
return cursor === 35 ? 30 : cursor - 1;
case "right":
if (column === 5) return undefined;
return cursor === 30 ? 35 : cursor + 1;
}
return undefined;
}
/** CheckSolvedUnownPuzzle: every place matches the solved layout. */
export function solved(reader: RomReader, pieces: readonly number[]) {
return pieces.every(
(piece, place) =>
piece ===
reader.byte(JOHTO["CheckSolvedUnownPuzzle.SolvedPuzzleConfiguration"] + place),
);
}
/** _UnownPuzzle (engine/games/unown_puzzle.asm), as UnownPuzzle calls it
* with the UNOWNPUZZLE_* in wScriptVar. A picks up the piece under the
* cursor or puts the held one down on an empty place; START quits. Placing
* the last piece right shows the finished picture until A or B. Returns
* wSolvedUnownPuzzle. */
export function* unownPuzzle(host: UnownPuzzleHost, puzzle: number): Gen<boolean> {
const r = host.reader,
tiles = unownPuzzleTiles(r, puzzle);
const state: UnownPuzzleState = {
pieces: initialPieces(host),
cursor: 0,
held: 0,
};
for (;;) {
const button = yield { kind: "screen", screen: puzzleScreen(r, tiles, puzzle, state) };
if (button === "start") return false;
if (button === "a") {
const at = state.pieces[state.cursor];
if (!state.held) {
// Nothing to pick up is an invalid action (SFX_WRONG).
if (!at) continue;
state.held = at;
state.pieces[state.cursor] = 0;
continue;
}
if (at) continue;
state.pieces[state.cursor] = state.held;
state.held = 0;
if (!solved(r, state.pieces)) continue;
// PlaceStartCancelBoxBorder clears START/CANCEL; ClearSprites; then
// SimpleWaitPressAorB.
const done = puzzleScreen(r, tiles, puzzle, state, true);
for (;;) {
const press = yield { kind: "screen", screen: done };
if (press === "a" || press === "b") return true;
}
}
if (typeof button === "string") {
const next = moveCursor(state.cursor, button);
if (next !== undefined) state.cursor = next;
}
}
}
/** The puzzle's screen: the border, the 12 x 12 void, the pieces on their
* places (UnownPuzzle_UpdateTilemap), START/CANCEL, and the cursor sprite
* (blinking while no piece is held) or the held piece. */
export function puzzleScreen(
reader: RomReader,
tiles: Uint8Array,
puzzle: number,
state: UnownPuzzleState,
finished = false,
): TileScreen {
const r = reader;
// _CGB_UnownPuzzle: PREDEFPAL_UNOWN_PUZZLE for the background and
// sprites; DmgToCgbObjPal0 with $24 maps the cursor's color 3 to the red
// the layout put in color 0.
const palette = romPalette(r, TRAINER_CARD.PredefPals + OW.PREDEFPAL_UNOWN_PUZZLE * 8);
const red = rgb(31);
const screen = blankScreen(tiles, `unown-puzzle-${puzzle}`, [palette], "UNOWN puzzle");
screen.objPalettes = [[red, palette[1], palette[2], red]];
fillBox(screen, 0, 0, 20, 18, PUZZLE_BORDER);
fillBox(screen, 4, 3, 12, 12, PUZZLE_VOID);
const entry = (place: number) => JOHTO.UnownPuzzleCoordData + place * 6;
const corner = (piece: number) =>
r.byte(JOHTO["GetCurrentPuzzlePieceVTileCorner.Corners"] + piece);
for (let place = 0; place < PLACES; place++) {
const cell = r.word(entry(place) + 2) - WRAM.wTilemap,
x = cell % SCREEN_WIDTH,
y = Math.floor(cell / SCREEN_WIDTH);
const piece = state.pieces[place];
if (!piece) {
fillBox(screen, x, y, 3, 3, r.byte(entry(place) + 4));
continue;
}
for (let row = 0; row < 3; row++)
for (let column = 0; column < 3; column++)
screen.map[(y + row) * SCREEN_WIDTH + x + column] =
corner(piece) + row * 12 + column;
}
// PlaceStartCancelBox: the box border at (4, 15), then $f6-$ff inside;
// once solved only the border remains, filled with the void.
fillBox(screen, 4, 15, 1, 1, 0xf0);
fillBox(screen, 5, 15, 10, 1, 0xf1);
fillBox(screen, 15, 15, 1, 1, 0xf2);
fillBox(screen, 4, 16, 1, 1, 0xf3);
fillBox(screen, 15, 16, 1, 1, 0xf3);
fillBox(screen, 4, 17, 1, 1, 0xf4);
fillBox(screen, 5, 17, 10, 1, 0xf1);
fillBox(screen, 15, 17, 1, 1, 0xf5);
for (let i = 0; i < 10; i++)
screen.map[16 * SCREEN_WIDTH + 5 + i] = finished ? PUZZLE_VOID : 0xf6 + i;
if (finished) return screen;
// RedrawUnownPuzzlePieces: OAM_HoldingPiece or OAM_NotHoldingPiece at
// the cursor's OAM coordinates (dbpixel), from the held piece's corner
// tile or the cursor's ($e0).
const x = r.byte(entry(state.cursor)),
y = r.byte(entry(state.cursor) + 1);
const layout = state.held
? JOHTO["RedrawUnownPuzzlePieces.OAM_HoldingPiece"]
: JOHTO["RedrawUnownPuzzlePieces.OAM_NotHoldingPiece"];
const base = state.held ? corner(state.held) : CURSOR_TILE;
const sprites: ScreenSprite[] = [];
for (let p = layout; r.byte(p) !== 0xff; p += 4) {
const attributes = r.byte(p + 3);
sprites.push({
// OAM coordinates are 16 (y) and 8 (x) beyond the screen's.
y: ((y + r.byte(p)) & 0xff) - 16,
x: ((x + r.byte(p + 1)) & 0xff) - 8,
tile: (base + r.byte(p + 2)) & 0xff,
palette: 0,
...(attributes & 0x20 && { flipX: true }),
...(attributes & 0x40 && { flipY: true }),
...(!state.held && { blink: true }),
});
}
screen.sprites = sprites;
return screen;
}
+3
View File
@@ -48,6 +48,9 @@ export const OW = {
ENGINE_ALWAYS_ON_BIKE: 25,
ENGINE_DOWNHILL: 26,
ENGINE_ZEPHYRBADGE: 27,
ENGINE_UNLOCKED_UNOWNS_A_TO_K: 43,
ENGINE_UNOWN_DEX: 12,
PREDEFPAL_UNOWN_PUZZLE: 76,
ENGINE_HIVEBADGE: 28,
ENGINE_PLAINBADGE: 29,
ENGINE_FOGBADGE: 30,
+73 -6
View File
@@ -12,7 +12,9 @@ import {
NUM_FRUIT_TREES,
OVERWORLD,
PC,
SAVED_WRAM,
SYMBOLS,
type SavedByte,
} from "../rom/profile";
import { OW } from "./constants";
import {
@@ -56,6 +58,7 @@ import {
} from "../scripts/interpreter";
import { IntroRun } from "../scripts/intro";
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen";
import { POKEDEX_BUTTONS, type PokedexView } from "../scripts/pokedex";
import { regionAtlas, type AtlasRegion, type AtlasView } from "./atlas";
import {
@@ -170,6 +173,11 @@ export interface NativeSave {
blueCardBalance: number;
/** wTradeFlags: the in-game trades done, one bit per NPCTRADE_*. */
tradeFlags: number;
/** Saved WRAM bytes scripts use directly, by PRET label (SAVED_WRAM). */
memory: Partial<Record<SavedByte, number>>;
/** wUnownDex and wFirstUnownSeen. */
unownDex: number[];
firstUnownSeen: number;
/** The option bytes, saved with the game as sOptions is. */
options: GameOptions;
}
@@ -319,6 +327,8 @@ 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 }
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| {
kind: "martquantity";
@@ -338,7 +348,8 @@ const buttonChoice = (choice: Choice) =>
choice.kind === "trainercard" ||
choice.kind === "pokedex" ||
choice.kind === "options" ||
choice.kind === "flymap";
choice.kind === "flymap" ||
choice.kind === "screen";
export interface Dialogue {
pages: string[];
index: number;
@@ -1697,8 +1708,14 @@ export class GameWorld {
for (const threshold of [35, 65, 85, 95])
if (variety >= Math.floor((threshold * 255) / 100)) chosen.level++;
}
// ValidateTempWildMonSpecies; Unown needs unlocked Unown (not ported).
if (!chosen.species || chosen.species > 251 || chosen.species === C.UNOWN)
// ValidateTempWildMonSpecies; no Unown before a letter set is unlocked.
if (!chosen.species || chosen.species > 251) return undefined;
if (
chosen.species === C.UNOWN &&
!Array.from({ length: 8 }, (_, bit) => OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K + bit).some(
(flag) => this.state.flags.has(flag),
)
)
return undefined;
return chosen;
}
@@ -2086,6 +2103,10 @@ export class GameWorld {
environment: () => this.assets.map(mapId).environment,
phoneService: () => this.assets.map(mapId).phoneService,
facingObject: () => this.facingObject(),
tileset: () => ({
graphics: this.assets.tileset(this.map).graphics,
palettes: this.assets.palettes(this.map, this.timeOfDay),
}),
};
}
/** FishFunction with rod 0-2 (Old, Good, Super): never while surfing,
@@ -2230,7 +2251,10 @@ export class GameWorld {
}
/** Select in PlayerEvents (SelectMenuScript): the registered item. */
useSelect(): boolean {
if (this.dialogue?.choice?.kind === "pokegear") {
if (
this.dialogue?.choice?.kind === "pokegear" ||
this.dialogue?.choice?.kind === "screen"
) {
this.choose("select");
return true;
}
@@ -2243,7 +2267,11 @@ 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 {
if (!select && this.dialogue?.choice?.kind === "pokegear") {
if (
!select &&
(this.dialogue?.choice?.kind === "pokegear" ||
this.dialogue?.choice?.kind === "screen")
) {
this.choose("start");
return true;
}
@@ -2555,6 +2583,11 @@ export class GameWorld {
},
shift: shiftBattleStyle(draft.options),
battleScene: battleSceneOn(draft.options),
unown: draft,
// wUnlockedUnowns: the ENGINE_UNLOCKED_UNOWNS_* flags' bits.
unlockedUnowns: Array.from({ length: 8 }, (_, bit) =>
draft.flags.has(OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K + bit) ? 1 << bit : 0,
).reduce((bits, bit) => bits | bit, 0),
});
this.battle = { engine, selected: 1 };
this.battleRun = engine.run();
@@ -2571,6 +2604,13 @@ export class GameWorld {
choice: { kind: "flymap", view: effect.view, selected: 0 },
};
return true;
case "screen":
this.dialogue = {
pages: [""],
index: 0,
choice: { kind: "screen", screen: effect.screen, selected: 0 },
};
return true;
case "fly":
// FlyFromAnim's loop runs 129 frames after a DelayFrame, FlyToAnim's
// 65: 65 and 33 overworld ticks.
@@ -4246,7 +4286,9 @@ export class GameWorld {
(dialogue.choice.kind === "options" &&
!OPTIONS_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "flymap" &&
!FLY_MAP_BUTTONS.includes(answer as never))
!FLY_MAP_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "screen" &&
!SCREEN_BUTTONS.includes(answer as never))
)
return;
this.townMap = false;
@@ -4326,6 +4368,9 @@ export class GameWorld {
coins: state.coins,
tradeFlags: state.tradeFlags,
blueCardBalance: state.blueCardBalance,
memory: { ...state.memory },
unownDex: [...state.unownDex],
firstUnownSeen: state.firstUnownSeen,
options: { ...state.options },
};
}
@@ -4447,6 +4492,17 @@ export class GameWorld {
!integer(save.coins, 0, C.MAX_COINS) ||
!integer(save.tradeFlags, 0, 0x7f) ||
!integer(save.blueCardBalance, 0, 255) ||
!save.memory ||
typeof save.memory !== "object" ||
Object.entries(save.memory).some(
([label, byte]) =>
!Object.hasOwn(SAVED_WRAM, label) || !integer(byte, 0, 255),
) ||
!Array.isArray(save.unownDex) ||
save.unownDex.length > 26 ||
!save.unownDex.every((letter) => integer(letter, 1, 26)) ||
new Set(save.unownDex).size !== save.unownDex.length ||
!integer(save.firstUnownSeen, 0, 26) ||
!validOptions(save.options)
) {
throw new Error(
@@ -4519,6 +4575,9 @@ export class GameWorld {
coins: save.coins,
tradeFlags: save.tradeFlags,
blueCardBalance: save.blueCardBalance,
memory: { ...save.memory },
unownDex: [...save.unownDex],
firstUnownSeen: save.firstUnownSeen,
options: { ...save.options },
specialPhoneCall: save.specialPhoneCall,
rivalName: save.rivalName,
@@ -4648,6 +4707,11 @@ export function migrateSave(
}
: {}),
...(!Object.hasOwn(save, "tradeFlags") && { tradeFlags: 0 }),
...(!Object.hasOwn(save, "memory") && { memory: {} }),
...(!Object.hasOwn(save, "unownDex") && {
unownDex: [],
firstUnownSeen: 0,
}),
...(!Object.hasOwn(save, "intro") && { intro: false }),
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
@@ -4701,6 +4765,9 @@ export function migrateSave(
coins: 0,
blueCardBalance: 0,
tradeFlags: 0,
memory: {},
unownDex: [],
firstUnownSeen: 0,
intro: false,
gameTime: emptyGameTime(),
options: defaultOptions(),