Measured on the cartridge from the rung-10 checkpoint: `wXCoord` and `wYCoord` change at the *end* of a sixteen-frame step while the background scrolls throughout it, so for fifteen frames of every sixteen the screen buffer is centred one tile ahead of the coordinates. The cross-check compared the decode of the fly's own tile with the screen's reading of the tile ahead and refused. Pewter City decoded on 118 of 120 standing frames and on none of the moving ones, so every walk the fly actually took was re-planned over the ten-by-nine window, which is the oscillation of row 23. Nothing in the pinned symbol table says "a step is in progress" and a new address cannot be pinned without the disassembly `gen_symbols.py` reads, so the anchor is measured rather than named: the screen is centred on the fly's tile or on one of its four neighbours, and the one it is centred on is the one whose whole neighbourhood agrees with the decode. A decode with a wrong stride, a wrong quadrant or a half-loaded map agrees with none of the five, and neither does the mid-warp tear the per-serve check was added for, so both refusals stand. `state::step_destination` names the tile the step is landing on, for the ledger the next commit writes. The town fixture gains two landmarks beside the fly, because a neighbourhood that is the same tile id in every direction cannot tell one anchor from another.
485 lines
21 KiB
Rust
485 lines
21 KiB
Rust
//! Synthetic WRAM for the scene and accessor tests.
|
|
//!
|
|
//! A flat 64 KiB address space with a [`MemoryReader`] over it, plus builders that write the byte
|
|
//! patterns the real cartridge produces. The patterns are the interesting part: each one is
|
|
//! assembled from the same disassembly evidence as the accessor it exercises, so a test that
|
|
//! passes here is a test against what `docs/design/macros-wram.md` claims, not against the
|
|
//! implementation's own opinion. The ROM-gated tests in `tests/rom_scene.rs` are what check the
|
|
//! claims against the cartridge.
|
|
//!
|
|
//! The space covers ROM bank 0 as well as WRAM, because one accessor reads it: the tileset
|
|
//! collision lists live at `00:17xx` and [`Wram::house_collision`] puts the real `RedsHouse1_Coll`
|
|
//! bytes there.
|
|
|
|
use crate::adapter::MemoryReader;
|
|
|
|
use super::state::poke;
|
|
use super::symbols::ram;
|
|
|
|
/// `constants/map_constants.asm`.
|
|
pub const REDS_HOUSE_1F: u8 = 0x25;
|
|
pub const PALLET_TOWN: u8 = 0x00;
|
|
pub const OAKS_LAB: u8 = 0x28;
|
|
|
|
/// `data/tilesets/collision_tile_ids.asm`: `RedsHouse1_Coll` and `RedsHouse2_Coll` share a list.
|
|
pub const REDS_HOUSE_COLL: [u8; 9] =
|
|
[0x01, 0x02, 0x03, 0x11, 0x12, 0x13, 0x14, 0x1c, 0x1a];
|
|
/// Where that list sits in the cartridge (`pokered.sym`: `00:1749 RedsHouse1_Coll`).
|
|
pub const REDS_HOUSE_COLL_ADDRESS: u16 = 0x1749;
|
|
|
|
/// A tile id that is in no collision list in the game, for "this tile is a wall".
|
|
pub const WALL_TILE: u8 = 0x60;
|
|
|
|
pub struct Wram {
|
|
bytes: Vec<u8>,
|
|
/// Fake cartridge banks, for the one read that needs one.
|
|
///
|
|
/// A bank nothing has written answers `None`, which is what a seam with no cartridge behind
|
|
/// it answers and what the whole-map grid has to narrow on
|
|
/// (`docs/design/macros.md` section 15).
|
|
rom: std::collections::HashMap<(u8, u16), u8>,
|
|
}
|
|
|
|
impl MemoryReader for Wram {
|
|
fn read8(&mut self, address: u16) -> u8 {
|
|
self.bytes[address as usize]
|
|
}
|
|
|
|
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
|
|
self.rom.get(&(bank, address)).copied()
|
|
}
|
|
}
|
|
|
|
impl Default for Wram {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl Wram {
|
|
/// All zero: the title screen, since nothing has set the game-timer bit.
|
|
pub fn new() -> Self {
|
|
Self { bytes: vec![0; 0x1_0000], rom: std::collections::HashMap::new() }
|
|
}
|
|
|
|
pub fn set(&mut self, address: u16, value: u8) -> &mut Self {
|
|
self.bytes[address as usize] = value;
|
|
self
|
|
}
|
|
|
|
/// A big-endian 16-bit quantity, which is how the cartridge stores HP.
|
|
pub fn set_word_be(&mut self, address: u16, value: u16) -> &mut Self {
|
|
self.set(address, (value >> 8) as u8).set(address + 1, (value & 0xff) as u8)
|
|
}
|
|
|
|
pub fn peek(&self, address: u16) -> u8 {
|
|
self.bytes[address as usize]
|
|
}
|
|
|
|
/// One byte of the screen's tile buffer.
|
|
pub fn screen_tile(&mut self, x: u16, y: u16, tile: u8) -> &mut Self {
|
|
self.set(ram::wTileMap + y * poke::SCREEN_WIDTH + x, tile)
|
|
}
|
|
|
|
/// Fill the whole screen buffer with one tile id.
|
|
pub fn fill_screen(&mut self, tile: u8) -> &mut Self {
|
|
for index in 0..poke::SCREEN_WIDTH * poke::SCREEN_HEIGHT {
|
|
self.set(ram::wTileMap + index, tile);
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Draw a `TextBoxBorder` box: the four corners are what the detector looks at, and the edges
|
|
/// are drawn too so the pattern is the one the game leaves behind.
|
|
pub fn draw_box(&mut self, left: u16, top: u16, right: u16, bottom: u16) -> &mut Self {
|
|
for x in left..=right {
|
|
self.screen_tile(x, top, poke::frame::HORIZONTAL);
|
|
self.screen_tile(x, bottom, poke::frame::HORIZONTAL);
|
|
}
|
|
for y in top..=bottom {
|
|
self.screen_tile(left, y, poke::frame::VERTICAL);
|
|
self.screen_tile(right, y, poke::frame::VERTICAL);
|
|
}
|
|
self.screen_tile(left, top, poke::frame::TOP_LEFT);
|
|
self.screen_tile(right, top, poke::frame::TOP_RIGHT);
|
|
self.screen_tile(left, bottom, poke::frame::BOTTOM_LEFT);
|
|
self.screen_tile(right, bottom, poke::frame::BOTTOM_RIGHT);
|
|
self
|
|
}
|
|
|
|
/// The game has started: `MainMenu`'s game-timer bit.
|
|
pub fn started(&mut self) -> &mut Self {
|
|
self.set(ram::wStatusFlags6, poke::BIT_GAME_TIMER_COUNTING)
|
|
}
|
|
|
|
/// A loaded map: id, size in blocks, and the player's coordinates in tiles.
|
|
pub fn map(&mut self, id: u8, blocks_wide: u8, blocks_high: u8, x: u8, y: u8) -> &mut Self {
|
|
self.set(ram::wCurMap, id)
|
|
.set(ram::wCurMapWidth, blocks_wide)
|
|
.set(ram::wCurMapHeight, blocks_high)
|
|
.set(ram::wXCoord, x)
|
|
.set(ram::wYCoord, y)
|
|
}
|
|
|
|
/// Point `wTilesetCollisionPtr` at a real collision list, written where the cartridge keeps it.
|
|
pub fn house_collision(&mut self) -> &mut Self {
|
|
for (offset, tile) in REDS_HOUSE_COLL.iter().enumerate() {
|
|
self.set(REDS_HOUSE_COLL_ADDRESS + offset as u16, *tile);
|
|
}
|
|
self.set(REDS_HOUSE_COLL_ADDRESS + REDS_HOUSE_COLL.len() as u16, 0xff);
|
|
self.set(ram::wTilesetCollisionPtr, (REDS_HOUSE_COLL_ADDRESS & 0xff) as u8)
|
|
.set(ram::wTilesetCollisionPtr + 1, (REDS_HOUSE_COLL_ADDRESS >> 8) as u8)
|
|
}
|
|
|
|
/// Write a map tile's id into the screen buffer at the position the game would hold it, given
|
|
/// where the player is. Silently does nothing for a tile outside the screen's window, which is
|
|
/// exactly the tile the accessor must report as unknown.
|
|
pub fn map_tile(&mut self, x: u8, y: u8, tile: u8) -> &mut Self {
|
|
let player_x = self.peek(ram::wXCoord);
|
|
let player_y = self.peek(ram::wYCoord);
|
|
let screen_x = poke::PLAYER_SCREEN_X + 2 * (i32::from(x) - i32::from(player_x));
|
|
let screen_y = poke::PLAYER_SCREEN_Y + 2 * (i32::from(y) - i32::from(player_y));
|
|
if (0..poke::SCREEN_WIDTH as i32).contains(&screen_x)
|
|
&& (0..poke::SCREEN_HEIGHT as i32).contains(&screen_y)
|
|
{
|
|
self.screen_tile(screen_x as u16, screen_y as u16, tile);
|
|
}
|
|
self
|
|
}
|
|
|
|
/// The player's facing, in sprite slot 0.
|
|
pub fn facing(&mut self, sprite_facing: u8) -> &mut Self {
|
|
self.set(ram::wSpriteStateData1 + 9, sprite_facing)
|
|
}
|
|
|
|
/// One party member, written into its 44-byte `party_struct`.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn party_mon(
|
|
&mut self,
|
|
slot: u8,
|
|
species: u8,
|
|
level: u8,
|
|
hp: u16,
|
|
max_hp: u16,
|
|
status: u8,
|
|
moves: &[(u8, u8)],
|
|
) -> &mut Self {
|
|
let base = ram::wPartyMon1 + u16::from(slot) * poke::PARTY_MON_BYTES;
|
|
self.set(ram::wPartySpecies + u16::from(slot), species);
|
|
self.set(base, species)
|
|
.set_word_be(base + 1, hp)
|
|
.set(base + 4, status)
|
|
.set(base + 33, level)
|
|
.set_word_be(base + 34, max_hp);
|
|
for (index, (id, pp)) in moves.iter().enumerate().take(4) {
|
|
self.set(base + 8 + index as u16, *id).set(base + 29 + index as u16, *pp);
|
|
}
|
|
let count = self.peek(ram::wPartyCount).max(slot + 1);
|
|
self.set(ram::wPartyCount, count)
|
|
}
|
|
|
|
/// The active battler's copy of a party entry.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn battle_mon(
|
|
&mut self,
|
|
slot: u8,
|
|
species: u8,
|
|
level: u8,
|
|
hp: u16,
|
|
max_hp: u16,
|
|
status: u8,
|
|
moves: &[(u8, u8)],
|
|
) -> &mut Self {
|
|
self.set(ram::wPlayerMonNumber, slot)
|
|
.set(ram::wBattleMonSpecies, species)
|
|
.set(ram::wBattleMonLevel, level)
|
|
.set_word_be(ram::wBattleMonHP, hp)
|
|
.set_word_be(ram::wBattleMonMaxHP, max_hp)
|
|
.set(ram::wBattleMonStatus, status);
|
|
for (index, (id, pp)) in moves.iter().enumerate().take(4) {
|
|
self.set(ram::wBattleMonMoves + index as u16, *id)
|
|
.set(ram::wBattleMonPP + index as u16, *pp);
|
|
}
|
|
self.set(ram::wNumMovesMinusOne, moves.len().clamp(1, 4) as u8 - 1)
|
|
}
|
|
|
|
pub fn enemy_mon(&mut self, species: u8, level: u8, hp: u16, max_hp: u16) -> &mut Self {
|
|
self.set(ram::wEnemyMonSpecies, species)
|
|
.set(ram::wEnemyMonLevel, level)
|
|
.set_word_be(ram::wEnemyMonHP, hp)
|
|
.set_word_be(ram::wEnemyMonMaxHP, max_hp)
|
|
}
|
|
|
|
/// `HandleMenuInput`'s state.
|
|
pub fn cursor(&mut self, top_y: u8, top_x: u8, current: u8, max: u8, keys: u8) -> &mut Self {
|
|
self.set(ram::wTopMenuItemY, top_y)
|
|
.set(ram::wTopMenuItemX, top_x)
|
|
.set(ram::wCurrentMenuItem, current)
|
|
.set(ram::wMaxMenuItem, max)
|
|
.set(ram::wMenuWatchedKeys, keys)
|
|
}
|
|
|
|
/// The four corner tiles of a box and nothing else, which is what a map can look like.
|
|
///
|
|
/// The overworld tilesets use the frame's own tile ids for ordinary ground, so these four
|
|
/// screen positions hold them from time to time — measured at 315 frames of 43,004 on the
|
|
/// cartridge (`infra/docs/macros-traps.md`, 2026-09-17). This is that pattern, for a test that
|
|
/// the detector is not fooled by it.
|
|
pub fn draw_box_corners(&mut self, left: u16, top: u16, right: u16, bottom: u16) -> &mut Self {
|
|
self.screen_tile(left, top, poke::frame::TOP_LEFT);
|
|
self.screen_tile(right, top, poke::frame::TOP_RIGHT);
|
|
self.screen_tile(left, bottom, poke::frame::BOTTOM_LEFT);
|
|
self.screen_tile(right, bottom, poke::frame::BOTTOM_RIGHT);
|
|
self
|
|
}
|
|
|
|
/// A text display is open, with the bottom-of-screen dialogue box drawn.
|
|
pub fn dialogue_box(&mut self) -> &mut Self {
|
|
self.set(ram::wFontLoaded, poke::BIT_FONT_LOADED).draw_box(0, 12, 19, 17)
|
|
}
|
|
|
|
/// The start menu, Pokédex entry included.
|
|
pub fn start_menu(&mut self) -> &mut Self {
|
|
self.set(ram::wFontLoaded, poke::BIT_FONT_LOADED)
|
|
.draw_box(10, 0, 19, 15)
|
|
.cursor(2, 11, 0, 7, poke::pad::DOWN | poke::pad::UP | poke::pad::START | poke::pad::B | poke::pad::A)
|
|
}
|
|
|
|
/// A wild or trainer battle, with no menu up yet.
|
|
pub fn battle(&mut self, is_in_battle: u8) -> &mut Self {
|
|
self.set(ram::wIsInBattle, is_in_battle)
|
|
.set(ram::wFontLoaded, poke::BIT_FONT_LOADED)
|
|
}
|
|
|
|
/// The top-level battle menu, in the left column (FIGHT / PKMN) or the right (ITEM / RUN).
|
|
pub fn battle_menu(&mut self, right_column: bool, current: u8) -> &mut Self {
|
|
let (x, keys) = if right_column {
|
|
(15, poke::pad::LEFT | poke::pad::A)
|
|
} else {
|
|
(9, poke::pad::RIGHT | poke::pad::A)
|
|
};
|
|
self.set(ram::wTextBoxID, poke::BATTLE_MENU_TEMPLATE).cursor(14, x, current, 1, keys)
|
|
}
|
|
|
|
/// The move list, `MoveSelectionMenu`'s regular menu. `slot` is the 0-based move.
|
|
pub fn move_menu(&mut self, slot: u8, moves: u8) -> &mut Self {
|
|
self.set(ram::wNumMovesMinusOne, moves.saturating_sub(1)).cursor(
|
|
12,
|
|
5,
|
|
slot + 1,
|
|
moves + 1,
|
|
poke::pad::UP | poke::pad::DOWN | poke::pad::A,
|
|
)
|
|
}
|
|
|
|
/// The party list. `forced` is the state `ChooseNextMon` leaves: A only, no way out.
|
|
pub fn party_list(&mut self, current: u8, forced: bool) -> &mut Self {
|
|
let count = self.peek(ram::wPartyCount).max(1);
|
|
let keys = if forced { poke::pad::A } else { poke::pad::A | poke::pad::B };
|
|
self.set(
|
|
ram::wPartyMenuTypeOrMessageID,
|
|
if forced { poke::BATTLE_PARTY_MENU } else { 0 },
|
|
)
|
|
.set(ram::wFontLoaded, poke::BIT_FONT_LOADED)
|
|
.cursor(1, 0, current, count - 1, keys)
|
|
}
|
|
|
|
/// Money, as three bytes of big-endian BCD.
|
|
pub fn money(&mut self, amount: u32) -> &mut Self {
|
|
let digits = amount.min(999_999);
|
|
let bcd = |value: u32| ((value / 10) << 4 | (value % 10)) as u8;
|
|
self.set(ram::wPlayerMoney, bcd(digits / 10_000))
|
|
.set(ram::wPlayerMoney + 1, bcd(digits / 100 % 100))
|
|
.set(ram::wPlayerMoney + 2, bcd(digits % 100))
|
|
}
|
|
|
|
/// The bag, as `(id, quantity)` pairs and a `$ff` terminator.
|
|
pub fn bag(&mut self, items: &[(u8, u8)]) -> &mut Self {
|
|
self.set(ram::wNumBagItems, items.len() as u8);
|
|
for (index, (id, count)) in items.iter().enumerate() {
|
|
self.set(ram::wBagItems + index as u16 * 2, *id)
|
|
.set(ram::wBagItems + index as u16 * 2 + 1, *count);
|
|
}
|
|
self.set(ram::wBagItems + items.len() as u16 * 2, 0xff)
|
|
}
|
|
|
|
/// One NPC sprite, in the slots the game keeps it: picture id and facing in
|
|
/// `wSpriteStateData1`, map coordinates plus four in `wSpriteStateData2`.
|
|
pub fn npc(&mut self, slot: u8, picture: u8, x: u8, y: u8, sprite_facing: u8) -> &mut Self {
|
|
let data1 = ram::wSpriteStateData1 + u16::from(slot) * poke::SPRITE_BYTES;
|
|
let data2 = ram::wSpriteStateData2 + u16::from(slot) * poke::SPRITE_BYTES;
|
|
self.set(data1, picture)
|
|
.set(data1 + 2, 0)
|
|
.set(data1 + 9, sprite_facing)
|
|
.set(data2 + 4, y + poke::SPRITE_COORD_BIAS)
|
|
.set(data2 + 5, x + poke::SPRITE_COORD_BIAS);
|
|
let count = self.peek(ram::wNumSprites).max(slot);
|
|
self.set(ram::wNumSprites, count)
|
|
}
|
|
|
|
/// The current map's sign table: `bg_event`s, `Y, X` per entry with no bias, and a text id
|
|
/// each.
|
|
pub fn signs(&mut self, signs: &[(u8, u8, u8)]) -> &mut Self {
|
|
self.set(ram::wNumSigns, signs.len() as u8);
|
|
for (index, (x, y, text_id)) in signs.iter().enumerate() {
|
|
let coords = ram::wSignCoords + index as u16 * 2;
|
|
self.set(coords, *y).set(coords + 1, *x).set(ram::wSignTextIDs + index as u16, *text_id);
|
|
}
|
|
self
|
|
}
|
|
|
|
/// The current map's warp table.
|
|
pub fn warps(&mut self, warps: &[(u8, u8, u8, u8)]) -> &mut Self {
|
|
self.set(ram::wNumberOfWarps, warps.len() as u8);
|
|
for (index, (x, y, destination_warp, destination_map)) in warps.iter().enumerate() {
|
|
let entry = ram::wWarpEntries + index as u16 * 4;
|
|
self.set(entry, *y)
|
|
.set(entry + 1, *x)
|
|
.set(entry + 2, *destination_warp)
|
|
.set(entry + 3, *destination_map);
|
|
}
|
|
self
|
|
}
|
|
|
|
|
|
/// The ROM bank and address this fake keeps a tileset's blockset at.
|
|
///
|
|
/// Any non-zero bank: the point of the number is that it is *not* bank 0, because a bank the
|
|
/// CPU bus does not have mapped is the whole reason the seam grew
|
|
/// [`MemoryReader::read_rom`] (`docs/design/macros.md` section 15).
|
|
pub const BLOCKSET_BANK: u8 = 0x11;
|
|
pub const BLOCKSET_BASE: u16 = 0x4000;
|
|
|
|
/// Which tileset the loaded map uses, for the tile-pair collision lists.
|
|
pub fn tileset(&mut self, id: u8) -> &mut Self {
|
|
self.set(ram::wCurMapTileset, id)
|
|
}
|
|
|
|
/// A tileset header's blockset, written where a cartridge keeps one: sixteen tile ids per
|
|
/// block, in block-id order, in a ROM bank that is not bank 0.
|
|
pub fn blockset(&mut self, blocks: &[[u8; 16]]) -> &mut Self {
|
|
for (id, block) in blocks.iter().enumerate() {
|
|
for (offset, tile) in block.iter().enumerate() {
|
|
let address = Self::BLOCKSET_BASE + (id * 16 + offset) as u16;
|
|
self.rom.insert((Self::BLOCKSET_BANK, address), *tile);
|
|
}
|
|
}
|
|
self.set(ram::wTilesetBank, Self::BLOCKSET_BANK)
|
|
.set(ram::wTilesetBlocksPtr, (Self::BLOCKSET_BASE & 0xff) as u8)
|
|
.set(ram::wTilesetBlocksPtr + 1, (Self::BLOCKSET_BASE >> 8) as u8)
|
|
}
|
|
|
|
/// The loaded map's block ids, as `LoadTileBlockMap` leaves them in `wOverworldMap`: rows of
|
|
/// `wCurMapWidth + MAP_BORDER * 2` bytes with the map itself three rows and three columns in.
|
|
///
|
|
/// `blocks` is row-major and `wCurMapWidth * wCurMapHeight` long; the border is left as
|
|
/// whatever it was, exactly as a map with no connections leaves it.
|
|
pub fn map_blocks(&mut self, blocks: &[u8]) -> &mut Self {
|
|
let width = u16::from(self.peek(ram::wCurMapWidth));
|
|
let height = u16::from(self.peek(ram::wCurMapHeight));
|
|
let border = crate::pokemon_red::mapgrid::MAP_BORDER as u16;
|
|
let stride = width + border * 2;
|
|
for row in 0..height {
|
|
for column in 0..width {
|
|
let index = usize::from(row * width + column);
|
|
let Some(block) = blocks.get(index) else { continue };
|
|
self.set(ram::wOverworldMap + (row + border) * stride + column + border, *block);
|
|
}
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Write the screen buffer so that it agrees with the block data, tile for tile.
|
|
///
|
|
/// The grid reader cross-checks its decode against the window predicate before it trusts it
|
|
/// ([`crate::pokemon_red::state::map_grid`]), and on a cartridge the two agree because they
|
|
/// are two readings of one map. This is that agreement in a fake: every map tile inside the
|
|
/// ten-by-nine window gets the tile id the blockset gives it, and the tiles outside it keep
|
|
/// whatever the screen held, which is what makes them `Unknown` to the window and answerable
|
|
/// only by the grid.
|
|
pub fn screen_from_blocks(&mut self, blocks: &[u8], blockset: &[[u8; 16]]) -> &mut Self {
|
|
let width = usize::from(self.peek(ram::wCurMapWidth));
|
|
let height = usize::from(self.peek(ram::wCurMapHeight));
|
|
for y in 0..height * 2 {
|
|
for x in 0..width * 2 {
|
|
let Some(block) = blocks.get((y / 2) * width + (x / 2)) else { continue };
|
|
let Some(tiles) = blockset.get(usize::from(*block)) else { continue };
|
|
// The lower-left tile of the map tile's own quadrant, which is the one the
|
|
// cartridge's collision read uses (`mapgrid::ANCHOR_ROW`).
|
|
let tile = tiles[((y % 2) * 2 + 1) * 4 + (x % 2) * 2];
|
|
self.map_tile(x as u8, y as u8, tile);
|
|
}
|
|
}
|
|
self
|
|
}
|
|
|
|
/// A map ten blocks by nine -- twenty tiles by eighteen, wider than the ten-by-nine window
|
|
/// -- with a wall down one column of blocks, and a screen buffer that agrees with it.
|
|
///
|
|
/// The three tables the grid is decoded from, all synthetic: block ids in `wOverworldMap`, a
|
|
/// blockset in a ROM bank that is not bank 0, and a collision list in bank 0 where
|
|
/// `wTilesetCollisionPtr` points. The blocks and the blockset come back so that a caller can
|
|
/// redraw the screen ([`Wram::mid_step`]).
|
|
pub fn town() -> (Self, Vec<u8>, Vec<[u8; 16]>) {
|
|
const FLOOR: u8 = 0x01;
|
|
let blockset = vec![[FLOOR; 16], [WALL_TILE; 16]];
|
|
let (wide, high) = (10usize, 9usize);
|
|
let mut blocks = vec![0u8; wide * high];
|
|
for row in 0..high {
|
|
blocks[row * wide + 5] = 1;
|
|
}
|
|
// Two landmarks beside the fly's own tile, one on each axis. A map whose neighbourhood is
|
|
// the same tile id in every direction cannot tell a view centred on the fly from a view
|
|
// centred one tile away, which is exactly what a mid-step frame is ([`Wram::mid_step`]).
|
|
// The fly stands on (3, 4) of the decoded map: these make (2..3, 2..3) and (0..1, 4..5)
|
|
// wall, leaving (3, 4) and every tile it can step to walkable.
|
|
blocks[wide + 1] = 1;
|
|
blocks[2 * wide] = 1;
|
|
let mut wram = Self::new();
|
|
wram.started()
|
|
.map(PALLET_TOWN, wide as u8, high as u8, 3, 4)
|
|
.facing(0)
|
|
.house_collision()
|
|
.tileset(0)
|
|
.blockset(&blockset)
|
|
.map_blocks(&blocks)
|
|
.fill_screen(WALL_TILE)
|
|
.screen_from_blocks(&blocks, &blockset);
|
|
(wram, blocks, blockset)
|
|
}
|
|
|
|
/// The screen buffer centred one tile away from `wXCoord` / `wYCoord`, which is what a frame
|
|
/// **mid-step** looks like on the cartridge.
|
|
///
|
|
/// Measured 2026-09-22 (`infra/docs/macros-traps.md` row 54): the coordinates change at the
|
|
/// *end* of a sixteen-frame step and the background scrolls throughout it, so for fifteen
|
|
/// frames of every sixteen the two readings are one tile apart in the direction of travel.
|
|
/// This draws exactly that: the view is rendered from `(x + dx, y + dy)` and the coordinates
|
|
/// are put back.
|
|
pub fn mid_step(
|
|
&mut self,
|
|
dx: i16,
|
|
dy: i16,
|
|
blocks: &[u8],
|
|
blockset: &[[u8; 16]],
|
|
) -> &mut Self {
|
|
let (x, y) = (self.peek(ram::wXCoord), self.peek(ram::wYCoord));
|
|
self.set(ram::wXCoord, (i16::from(x) + dx) as u8);
|
|
self.set(ram::wYCoord, (i16::from(y) + dy) as u8);
|
|
self.fill_screen(WALL_TILE).screen_from_blocks(blocks, blockset);
|
|
self.set(ram::wXCoord, x).set(ram::wYCoord, y);
|
|
self
|
|
}
|
|
|
|
/// A playable overworld frame: Red's ground floor, the fly standing where a cold boot's walk
|
|
/// out of the bedroom lands it, every tile a wall until a test opens one.
|
|
pub fn overworld() -> Self {
|
|
let mut wram = Self::new();
|
|
wram.started()
|
|
.map(REDS_HOUSE_1F, 4, 4, 3, 6)
|
|
.facing(0)
|
|
.house_collision()
|
|
.fill_screen(WALL_TILE);
|
|
wram
|
|
}
|
|
}
|