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

+173
View File
@@ -0,0 +1,173 @@
import type { CrystalAssets } from "../rom/assets";
import { OVERWORLD, SYMBOLS } from "../rom/profile";
import type { GameMap, Palette } from "../world/types";
/** vTiles2 in VRAM bank 0: animations write tileset tiles from here. */
const VTILES2 = 0x9000;
/** wTileAnimBuffer, the scratch tile that scrolling animations use. */
const TILE_ANIM_BUFFER = 0xcf41;
/** Tile animation routines by ROM address (engine/tilesets/tileset_anims.asm). */
const R = OVERWORLD;
/** _AnimateTileset (engine/tilesets/tileset_anims.asm): the tileset's own
* list of (parameter, routine) pairs from data/tileset_anims.asm, run one
* entry per video frame. Routines are selected by their ROM address and
* translated natively; tiles are written to a private copy of the tileset
* graphics, and the palette animations to color 0 of two palettes. */
export class TilesetAnimator {
readonly graphics: Uint8Array;
private readonly script: number;
/** hTileAnimFrame, wTileAnimationTimer and hVBlankCounter. */
private frame = 0;
private timer = 0;
private vblank = 0;
private buffer = new Uint8Array(16);
/** Which of the water palette's colors 0-2 replaces color 0, and the
* dark cave entrance's yellow color 0 or 1. */
private water = 0;
private cave?: number;
constructor(
private assets: CrystalAssets,
map: GameMap,
graphics: Uint8Array,
) {
const r = assets.reader;
this.graphics = new Uint8Array(graphics);
const tileset = SYMBOLS.tilesets + map.tileset * 15;
this.script = r.near(tileset + 9, r.bank(R.WaitTileAnimation));
}
/** Run `frames` video frames; `dark` is wTimeOfDayPalset's darkness. */
advance(frames: number, dark: boolean, restless = false) {
for (let count = 0; count < frames; count++) {
this.vblank = (this.vblank + 1) & 0xff;
this.step(dark, restless);
}
}
/** Apply the palette animations to the map's palettes. */
palettes(palettes: Palette[]): Palette[] {
const result = [...palettes];
// AnimateWaterPalette: PAL_BG_WATER color 0 cycles through colors 0-2.
if (this.water) result[3] = [palettes[3][this.water], ...palettes[3].slice(1)];
if (this.cave !== undefined)
result[4] = [palettes[4][this.cave], ...palettes[4].slice(1)];
return result;
}
private step(dark: boolean, restless: boolean) {
const r = this.assets.reader,
entry = this.script + this.frame * 4;
this.frame = (this.frame + 1) & 0xff;
const parameter = r.word(entry),
routine = r.address(r.bank(R.WaitTileAnimation), r.word(entry + 2));
const bank = r.bank(R.WaitTileAnimation);
const at = (address: number) => r.address(bank, address);
switch (routine) {
case R.WaitTileAnimation:
return;
case R.DoneTileAnimation:
this.frame = 0;
return;
case R.StandingTileFrame8:
this.timer = (this.timer + 1) & 7;
return;
case R.StandingTileFrame:
this.timer = (this.timer + 1) & 0xff;
return;
case R.ScrollTileRightLeft:
this.timer = (this.timer + 1) & 7;
return this.scroll(parameter, this.timer & 4 ? "left" : "right");
case R.ScrollTileUpDown:
this.timer = (this.timer + 1) & 7;
return this.scroll(parameter, this.timer & 4 ? "down" : "up");
case R.ScrollTileLeft:
return this.scroll(parameter, "left");
case R.ScrollTileRight:
return this.scroll(parameter, "right");
case R.ScrollTileUp:
return this.scroll(parameter, "up");
case R.ScrollTileDown:
return this.scroll(parameter, "down");
case R.ReadTileToAnimBuffer:
this.buffer.set(this.memory(parameter));
return;
case R.WriteTileFromAnimBuffer:
this.memory(parameter).set(this.buffer);
return;
case R.AnimateWaterTile:
// A cycle of 4 frames, updating every other tick.
return this.write(parameter, R["AnimateWaterTile.WaterTileFrames"] + (this.timer & 6) * 8);
case R.AnimateFountainTile: {
const table = R["AnimateFountainTile.FountainTileFramePointers"];
return this.write(parameter, at(r.word(table + (this.timer & 7) * 2)));
}
case R.AnimateFlowerTile:
// CGB flower frames follow the DMG ones in .FlowerTileFrames.
return this.write(VTILES2 + 0x03 * 16, R["AnimateFlowerTile.FlowerTileFrames"] + ((this.timer & 2) + 1) * 16);
case R.ForestTreeLeftAnimation:
case R.ForestTreeLeftAnimation2:
case R.ForestTreeRightAnimation:
case R.ForestTreeRightAnimation2: {
const right = routine === R.ForestTreeRightAnimation || routine === R.ForestTreeRightAnimation2;
const alternate = routine === R.ForestTreeLeftAnimation2 || routine === R.ForestTreeRightAnimation2;
// GetForestTreeFrame returns the odd frame for all timer values >= 7.
const frame = restless ? ((this.timer >= 7 || (this.timer & 1) ? 1 : 0) ^ Number(alternate)) : 0;
return this.write(VTILES2 + (right ? 0x0f : 0x0c) * 16,
(right ? R.ForestTreeRightFrames : R.ForestTreeLeftFrames) + frame * 16);
}
case R.AnimateLavaBubbleTile1:
return this.write(VTILES2 + 0x5b * 16, R.LavaBubbleTileFrames + ((((this.timer & 6) >> 1) + 2) & 3) * 16);
case R.AnimateLavaBubbleTile2:
return this.write(VTILES2 + 0x38 * 16, R.LavaBubbleTileFrames + (this.timer & 6) * 8);
case R.AnimateTowerPillarTile: {
const pointer = at(parameter);
const offset = r.byte(R["AnimateTowerPillarTile.TowerPillarTileFrameOffsets"] + (this.timer & 7));
return this.write(r.word(pointer), at(r.word(pointer + 2)) + offset);
}
case R.AnimateWhirlpoolTile: {
const pointer = at(parameter);
return this.write(r.word(pointer), at(r.word(pointer + 2)) + (this.timer & 3) * 16);
}
case R.AnimateWaterPalette:
// Only on even ticks: colors 0, 1, 2, 1.
if (this.timer & 1) return;
this.water = (this.timer & 6) === 0 ? 0 : (this.timer & 6) === 4 ? 2 : 1;
return;
case R.FlickeringCaveEntrancePalette:
// Only in a dark cave: yellow color 0 alternates with color 1.
this.cave = dark ? (this.vblank & 2 ? 1 : 0) : undefined;
return;
default:
throw new Error("Tileset animation routine is not ported.");
}
}
/** A tile in vTiles2 (bank 0), or wTileAnimBuffer. */
private memory(address: number): Uint8Array {
if (address === TILE_ANIM_BUFFER) return this.buffer;
const tile = (address - VTILES2) >> 4;
if (address < VTILES2 || (address & 15) || tile >= 0x80)
throw new Error("Tileset animation writes outside the tiles.");
return this.graphics.subarray(tile * 16, tile * 16 + 16);
}
private write(destination: number, source: number) {
this.memory(destination).set(this.assets.reader.slice(source, 16));
}
/** ScrollTile*: bytes rotate for left/right; rows rotate for up/down. */
private scroll(address: number, direction: "left" | "right" | "up" | "down") {
const tile = this.memory(address);
if (direction === "left" || direction === "right")
for (let i = 0; i < 16; i++)
tile[i] =
direction === "left"
? ((tile[i] << 1) | (tile[i] >> 7)) & 0xff
: ((tile[i] >> 1) | (tile[i] << 7)) & 0xff;
else {
const rows = Array.from({ length: 8 }, (_, row) => tile.slice(row * 2, row * 2 + 2));
for (let row = 0; row < 8; row++)
tile.set(rows[direction === "up" ? (row + 1) % 8 : (row + 7) % 8], row * 2);
}
}
}