Complete the overworld foundation
Checks / web (push) Canceled after 0s

Port forced movement (turn delays, bumps, ice, currents, pits), dynamic
return warps, elevators, Surf, Cut, Strength and cycling rules, working
map blocks with changeblock/changemapblocks, and the remaining NPC
movement types and movement commands.

Add tileset animation from the ROM's command tables, grass sprite
priority and special map palettes; room decorations with ownership, the
bedroom PC and save migration; and Key Items access to the Bicycle.

Fourteen original-engine traces compare positions, facing, actors, event
flags and scripted poses. Tile animation matches original VRAM for four
tilesets, and LoadMapPals matches 192 palette cases. skyfall_top now
keeps the $60 sprite offset its step function leaves behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-25 16:47:29 +02:00
1 parent 9ac111f262
commit 451464f7c6
38 files changed
+5600 -490

No files matched your search

+87 -15
View File
@@ -6,11 +6,13 @@ import {
NATIVE_MAPS,
NUM_ENGINE_FLAGS,
NUM_SPRITEMOVEDATA,
OVERWORLD,
RAM,
SYMBOLS,
} from "./profile";
import { defaultTextContext, readString } from "../scripts/text";
import { PokemonData } from "./pokemon";
import { OW } from "../world/constants";
import type {
Direction,
GameMap,
@@ -308,6 +310,35 @@ export class CrystalAssets {
];
});
}
/** LoadSpecialMapPalette: tilesets with their own eight palettes, which
* do not follow the time of day. */
private specialPalettes(map: GameMap): Palette[] | undefined {
const eight = (address: number) =>
Array.from({ length: 8 }, (_, i) => this.palette(address + i * 8));
switch (map.tileset) {
case OW.TILESET_POKECOM_CENTER:
return eight(OVERWORLD.PokeComPalette);
case OW.TILESET_BATTLE_TOWER_INSIDE:
return eight(OVERWORLD.BattleTowerInsidePalette);
case OW.TILESET_ICE_PATH:
// The Hall of Fame shares the tileset but is INDOOR.
return (map.environment & 7) === OW.INDOOR ? undefined : eight(OVERWORLD.IcePathPalette);
case OW.TILESET_HOUSE:
return eight(SYMBOLS.housePalettes);
case OW.TILESET_RADIO_TOWER:
return eight(OVERWORLD.RadioTowerPalette);
case OW.TILESET_MANSION: {
// LoadMansionPalette: MansionPalette1, then yellow from
// MansionPalette2, water from palette 6 and roof from palette 8.
const palettes = eight(OVERWORLD.MansionPalette1);
palettes[4] = this.palette(OVERWORLD.MansionPalette2);
palettes[3] = this.palette(OVERWORLD.MansionPalette1 + 6 * 8);
palettes[6] = this.palette(OVERWORLD.MansionPalette1 + 8 * 8);
return palettes;
}
}
return undefined;
}
palettes(map: GameMap, time: number): Palette[] {
const r = this.reader;
const group =
@@ -316,13 +347,11 @@ export class CrystalAssets {
r.bank(SYMBOLS.environmentColors),
) +
time * 8;
const palettes = Array.from({ length: 8 }, (_, i) =>
this.palette(
map.tileset === 5
? SYMBOLS.housePalettes + i * 8
: SYMBOLS.backgroundPalettes + r.byte(group + i) * 8,
),
);
const palettes =
this.specialPalettes(map) ??
Array.from({ length: 8 }, (_, i) =>
this.palette(SYMBOLS.backgroundPalettes + r.byte(group + i) * 8),
);
if (map.environment === 1 || map.environment === 2) {
const roof = SYMBOLS.roofPalettes + map.group * 8 + (time >= 2 ? 4 : 0);
const colors = this.palette(roof);
@@ -344,13 +373,15 @@ export class CrystalAssets {
}
throw new Error("Grass collision list is unterminated.");
}
/** JohtoGrassWildMons for a map: encounter rates and seven slots per
* time of day (data/wild/johto_grass.asm). */
/** JohtoGrassWildMons or KantoGrassWildMons for a map: encounter rates
* and seven slots per time of day (data/wild/johto_grass.asm). */
grassWildMons(
id: string,
kanto = false,
): { rates: number[]; slots: { level: number; species: number }[][] } | undefined {
const r = this.reader;
for (let p = SYMBOLS.johtoGrassWildMons, i = 0; i < 256; i++, p += 47) {
const table = kanto ? OVERWORLD.KantoGrassWildMons : SYMBOLS.johtoGrassWildMons;
for (let p = table, i = 0; i < 256; i++, p += 47) {
const group = r.byte(p);
if (group === 0xff) return undefined;
if (`${group}:${r.byte(p + 1)}` !== id) continue;
@@ -365,6 +396,28 @@ export class CrystalAssets {
}
throw new Error("Wild encounter data is unterminated.");
}
/** JohtoWaterWildMons or KantoWaterWildMons: one rate and three slots
* (data/wild/johto_water.asm). */
waterWildMons(
id: string,
kanto = false,
): { rate: number; slots: { level: number; species: number }[] } | undefined {
const r = this.reader;
const table = kanto ? OVERWORLD.KantoWaterWildMons : OVERWORLD.JohtoWaterWildMons;
for (let p = table, i = 0; i < 256; i++, p += 9) {
const group = r.byte(p);
if (group === 0xff) return undefined;
if (`${group}:${r.byte(p + 1)}` !== id) continue;
return {
rate: r.byte(p + 2),
slots: Array.from({ length: 3 }, (_, slot) => ({
level: r.byte(p + 3 + slot * 2),
species: r.byte(p + 4 + slot * 2),
})),
};
}
throw new Error("Wild encounter data is unterminated.");
}
/** Engine flags kept in the four bytes CheckDailyResetTimer clears
* (wDailyFlags1 through wUnusedDailyFlag), from the ROM's EngineFlags. */
dailyEngineFlags(): number[] {
@@ -381,13 +434,20 @@ export class CrystalAssets {
* function, flags1 (FIXED_FACING, NOCLIP_*, ...) and palette flags. */
spriteMovement(type: number): {
fn: number;
/** OBJECT_ACTION_* the object starts with. */
action: number;
flags: number;
paletteFlags: number;
} {
if (type >= NUM_SPRITEMOVEDATA) throw new Error("Unknown movement type.");
const r = this.reader,
p = SYMBOLS.spriteMovementData + type * 6;
return { fn: r.byte(p), flags: r.byte(p + 3), paletteFlags: r.byte(p + 5) };
return {
fn: r.byte(p),
action: r.byte(p + 2),
flags: r.byte(p + 3),
paletteFlags: r.byte(p + 5),
};
}
/** Emote bubble graphics (data/sprites/emotes.asm). */
emote(index: number): Uint8Array {
@@ -399,13 +459,25 @@ export class CrystalAssets {
r.byte(entry + 2),
);
}
/** GetSprite: overworld sprites, and Pokémon drawn from their party
* menu icons (GetMonSprite). Variable sprites are resolved by the world
* from wVariableSprites first; day-care Pokémon are not ported. */
sprite(id: number): Sprite | undefined {
// Variable decorations and Pokémon icons need separate decoders.
if (id < 1 || id >= 0xa0) return undefined;
const cached = this.sprites.get(id);
if (cached) return cached;
const r = this.reader,
p = SYMBOLS.sprites + (id - 1) * 6;
const r = this.reader;
if (id >= OW.SPRITE_POKEMON && id < OW.SPRITE_POKEMON + OW.NUM_POKEMON_SPRITES) {
// LoadOverworldMonIcon: eight tiles, two frames in the down and up
// standing slots, with palette 0.
const species = r.byte(OVERWORLD.SpriteMons + id - OW.SPRITE_POKEMON);
const icon = r.byte(OVERWORLD.MonMenuIcons + species - 1);
const pointer = r.near(OVERWORLD.IconPointers + icon * 2, r.bank(OVERWORLD.IconPointers));
const sprite = { graphics: r.slice(pointer, 8 * 16), palette: 0, walking: true };
this.sprites.set(id, sprite);
return sprite;
}
if (id < 1 || id > OW.NUM_OVERWORLD_SPRITES) return undefined;
const p = SYMBOLS.sprites + (id - 1) * 6;
const length = r.byte(p + 2),
walking = r.byte(p + 4) === 1;
const sprite = {