Maps as Grids of Game State
Understand a map as indexed game state, then trace and visualize one tile's visibility in an offline lab.
Cheatsheet for Lesson 4.5
A one-page summary of “Maps as Grids of Game State” you can keep beside your notes. Plain Markdown.
Lesson 4.5 — Maps as Grids of Game State: cheatsheet
- Remember: A flat list can hold a grid: a tile's index is its row times the width plus its column. Check the bounds before you use it.
- Also useful: Think of fog as a sheet of tracing paper over the map: the terrain underneath is shared, but each player has their own sheet showing what they have seen.
Key terms
unwrap: Theunwrapis safe here becausechunks_exact(4)guarantees every chunk has four bytes, but an explicit conversion helper can make that invariant clearer in shared code.cl:mov eax, ebpthenshl eax, clbuilds a single set bit, shifted into the positionclnames — the bit that belongs to one specific tile in that column.enable_wesnoth_map_reveal: The injected implementation isenable_wesnoth_map_reveal.
Formulas and patterns
index = y × width + x
Review
- Q: A row-major visibility map is
widthtiles wide. Which formula finds tile(x, y)in a flat array? A: y × width + x - Q: A map is 4 columns wide and 3 rows tall, stored row by row. What flat index does tile (x = 1, y = 2) have? A: 9, since 2 x 4 + 1 = 9
A player record is one object. A map asks the same memory question thousands of times: which tile is this, and what does this player know about it? In a Wesnoth match, terrain and visibility may describe the same location but live in different layers of state. Coordinates give us a way to relate them.
A map is usually a grid
Section titled “A map is usually a grid”A rectangular map can be flattened into a one-dimensional array:
index = y × width + xThis interactive lab needs JavaScript. The lesson text around it still explains the same idea.
Make the conversion checked so an invalid tile cannot silently wrap:
fn tile_index(x: usize, y: usize, width: usize, height: usize) -> Option<usize> { if x >= width || y >= height { return None; } y.checked_mul(width)?.checked_add(x)}On a three-column, two-row map, tile (2, 1) becomes index 1 × 3 + 2 = 5.
Tile (3, 1) is outside the map and returns None. The checked multiplication
also refuses dimensions so large that the index would overflow usize.
Never trust coordinates from remote memory until they pass bounds checks.
A grid needs a coordinate contract
Section titled “A grid needs a coordinate contract”The formula only works after you know what x, y, width, and the base address mean. Games may store rows from top to bottom or bottom to top, pad each row, split terrain and visibility into separate layers, or keep one visibility layer per team.
For a candidate grid, recover these facts separately:
- origin: which tile is
(0, 0); - axis direction: which way increasing
xandymove; - stride: how many elements or bytes separate two rows;
- element width: how many bytes represent one tile;
- layer: terrain, occupancy, visibility, or another property;
- owner: global map state or one player’s derived view.
If the row stride includes padding, y * width + x is wrong even when the first row looks perfect. The byte address becomes base + y * row_stride + x * element_size. Verify it with tiles on different rows and near both edges of the map.
Predict first
A map is 3 tiles wide and 2 tall, one byte per tile. The game pads every row to 4 bytes, so each row ends with one unused byte.
Counting from byte 0, which byte holds tile (2, 1)?
Answer: Byte 6. Skip one whole row of 4 bytes (1 × 4 = 4), then move 2 tiles along it: 4 + 2 = 6. Bytes 3 and 7 are the padding.
Index, then the same byte in each layer
Index from (x, y)
Two layers in memory, one byte per tile
Skip 1 row of 4 bytes (1 × 4 = 4), then move 2 along: byte 6. Both layers keep tile (2, 1) at that byte.
Visibility is often a derived view of terrain, units, teams, and line-of-sight checks for range and blocking terrain. A cached visibility layer can be useful to read without being the source of truth, because the game rebuilds it from those inputs. What matters is which system recomputes it and when.
Find one known tile
Section titled “Find one known tile”In an offline Wesnoth match, compare a currently visible tile with a tile hidden by fog. Search for the observed visibility value, reveal or hide the tile through normal gameplay, and filter again.

Repeat with several tiles. You are testing whether candidates belong to:
- one tile;
- one row;
- one player’s visibility layer;
- a cached drawing value;
- the authoritative map state.
Break on a normal update
Section titled “Break on a normal update”Set a write breakpoint on a confirmed tile and move a unit so visibility updates.

Look for:
- a base pointer to map data;
- row or column math;
- a player or team index;
- the value written for hidden, explored, and visible states.
Do not assume a value such as 0xFFFF_FFFF means “visible.” It may mean -1, a bit mask, or an uninitialized sentinel. Collect examples for every state you name.
Model visibility as states
Section titled “Model visibility as states”Suppose repeated observations show 0 for hidden tiles, 1 for explored
tiles, and 2 for visible tiles in this exact build. Decode those raw values
into named states so later code cannot confuse visibility with terrain. Keep
every other number as Unknown until it has been investigated.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]enum Visibility { Hidden, Explored, Visible, Unknown(u32),}
fn decode_visibility(raw: u32) -> Visibility { match raw { 0 => Visibility::Hidden, 1 => Visibility::Explored, 2 => Visibility::Visible, other => Visibility::Unknown(other), }}Keep Unknown. Silently treating a new value as visible would hide the fact that the byte has a state the model does not know about.
Read a bounded layer
Section titled “Read a bounded layer”Give the reader a verified layer base and map dimensions. It first checks
width × height, caps the tile count, copies four bytes per tile from the
game process, then decodes each copied u32 with the mapping above. The
result is an owned vector of visibility states, not a pointer into live game
memory.
fn read_visibility_layer( process: &Process, base: usize, width: usize, height: usize,) -> anyhow::Result<Vec<Visibility>> { let tile_count = width.checked_mul(height) .context("map dimensions overflowed")?; anyhow::ensure!(tile_count <= 1_000_000, "map is unexpectedly large");
let mut bytes = vec![0_u8; tile_count * 4]; process.read_exact(base, &mut bytes)?;
Ok(bytes.chunks_exact(4) .map(|chunk| decode_visibility(u32::from_le_bytes(chunk.try_into().unwrap()))) .collect())}The unwrap is safe here because chunks_exact(4) guarantees every chunk has four bytes, but an explicit conversion helper can make that invariant clearer in shared code.
Prefer an external visualization
Section titled “Prefer an external visualization”Instead of patching the game, print a small text map or draw a separate window for your local lab:
? ? ? ? ?? . . @ ?? . # . ?? ? ? ? ?That keeps the experiment observable and easy to shut down.

Reproduce the original Wesnoth map patch
Section titled “Reproduce the original Wesnoth map patch”The original course called this exercise the map hack.
For Wesnoth 1.14.9, the write-breakpoint trail leads to 0x006CD519. The original eight-byte sequence updates a visibility column.
This build does not store one u32 per tile the way the model earlier in this
lesson does. It packs 32 tiles into a single 32-bit column, one bit per
tile, so the instructions below are bit-level field assignment rather than the
y * width + x array indexing shown above. mov eax, ebp then shl eax, cl
builds a single set bit, shifted into the position cl names — the bit that
belongs to one specific tile in that column. not eax flips every bit, which
turns that into a mask that is 0 at the tile’s position and 1 everywhere
else. and dword ptr [esi], eax applies the mask to the stored column,
clearing only that one tile’s visibility bit while leaving the other 31 alone.
Replacing that full span with the following bytes forces the column bits to 1 using or [esi], 0xFF:
const MAP_VISIBILITY_HOOK: usize = 0x006C_D519;const ORIGINAL: [u8; 8] = [ 0x8B, 0xC5, // mov eax,ebp 0xD3, 0xE0, // shl eax,cl 0xF7, 0xD0, // not eax 0x21, 0x06, // and dword ptr [esi],eax];const REVEAL_PATCH: [u8; 8] = [ 0x90, 0x90, 0x90, // remove the original value preparation 0x83, 0x0E, 0xFF, // or dword ptr [esi], -1 0x90, 0x90,];The observation, grid model, and debugger experiment above are the core exercise here. For an automated repeat after Lesson 8.3 teaches patch ownership and DLL lifecycle, the verified patch object expresses the same byte check and restoration in code:
let plan = PatchPlan::new( MAP_VISIBILITY_HOOK, &ORIGINAL, &REVEAL_PATCH,)?;let mut patch = plan.apply(&process)?;Move a unit in a local match. As each visibility column is updated, every bit becomes 0xFFFF_FFFF, revealing the map as shown above. Call patch.restore() and start a fresh map to confirm fog behaves normally again.
The expected bytes are no longer an unnamed profile placeholder: they encode
the four instructions shown in the debugger. The injected implementation is
enable_wesnoth_map_reveal.
After injection, F3 toggles it and End restores it.
wesnoth_hooks.rs
Show the codeHide the code
//! Additional Wesnoth 1.14.9 hacks from the original map/stat lessons.
#![cfg(target_arch = "x86")]
use std::ptr;
use anyhow::Result;
use super::{LocalPatch, near_call, near_jump};
const PLAYER_ROOT: usize = 0x017E_ED18;
const GAME_OFFSET: usize = 0x0A90;
const SECOND_PLAYER_GOLD_OFFSET: usize = 0x0274;
const STAT_HOOK: usize = 0x005E_D129;
const STAT_PRINT_FUNCTION: usize = 0x005E_9630;
const STAT_RESUME: usize = 0x005E_D12E;
const MAP_UPDATE: usize = 0x006C_D519;
fn decimal_bytes(mut value: u32) -> [u8; 4] {
value = value.min(999);
let mut bytes = [0_u8; 4];
if value >= 100 {
bytes[0] = b'0' + (value / 100) as u8;
bytes[1] = b'0' + ((value / 10) % 10) as u8;
bytes[2] = b'0' + (value % 10) as u8;
} else if value >= 10 {
bytes[0] = b'0' + (value / 10) as u8;
bytes[1] = b'0' + (value % 10) as u8;
} else {
bytes[0] = b'0' + value as u8;
}
bytes
}
extern "C" fn prepend_second_player_gold(edx: usize) {
if edx == 0 {
return;
}
// SAFETY: all addresses are from the verified 32-bit Wesnoth profile. Each
// normal missing-match pointer is checked before the next dereference.
let player = unsafe { ptr::read_volatile(PLAYER_ROOT as *const u32) } as usize;
if player == 0 {
return;
}
// SAFETY: the player record stores a game/side pointer at +0xA90.
let game = unsafe { ptr::read_volatile((player + GAME_OFFSET) as *const u32) } as usize;
if game == 0 {
return;
}
// SAFETY: the second player's gold is the four-byte field at +0x274.
let gold = unsafe { ptr::read_volatile((game + SECOND_PLAYER_GOLD_OFFSET) as *const u32) };
// EDX points to the game's pointer to its output text buffer.
// SAFETY: this shape was observed at the exact hooked print call.
let output = unsafe { ptr::read_volatile(edx as *const u32) } as usize;
if output == 0 {
return;
}
let digits = decimal_bytes(gold);
// SAFETY: the original course cave writes exactly the first three bytes of
// this existing text buffer; the fourth byte in `digits` is only padding.
unsafe {
ptr::copy_nonoverlapping(digits.as_ptr(), output as *mut u8, 3);
}
}
#[unsafe(naked)]
unsafe extern "C" fn wesnoth_stat_cave() {
core::arch::naked_asm!(
"pushfd",
"pushad",
"push edx",
"call {prepend}",
"add esp, 4",
"popad",
"popfd",
// Replay the five-byte print call replaced by the detour.
"call {original}",
"jmp {resume}",
prepend = sym prepend_second_player_gold,
original = const STAT_PRINT_FUNCTION,
resume = const STAT_RESUME,
);
}
pub fn install_wesnoth_stat_hook() -> Result<LocalPatch> {
let expected = near_call(STAT_HOOK, STAT_PRINT_FUNCTION)?;
let cave = wesnoth_stat_cave as *const () as usize;
let replacement = near_jump(STAT_HOOK, cave)?;
LocalPatch::apply(STAT_HOOK, &expected, &replacement)
}
pub fn enable_wesnoth_map_reveal() -> Result<LocalPatch> {
// mov eax,ebp ; shl eax,cl ; not eax ; and dword ptr [esi],eax
const ORIGINAL: [u8; 8] = [0x8B, 0xC5, 0xD3, 0xE0, 0xF7, 0xD0, 0x21, 0x06];
// nop/nop/nop ; or dword ptr [esi],0xFFFFFFFF ; nop/nop
const REVEAL: [u8; 8] = [0x90, 0x90, 0x90, 0x83, 0x0E, 0xFF, 0x90, 0x90];
LocalPatch::apply(MAP_UPDATE, &ORIGINAL, &REVEAL)
}
#[cfg(test)]
mod tests {
use super::decimal_bytes;
#[test]
fn formats_the_three_bytes_used_by_the_course_cave() {
assert_eq!(&decimal_bytes(7)[..3], b"7\0\0");
assert_eq!(&decimal_bytes(42)[..3], b"42\0");
assert_eq!(&decimal_bytes(999)[..3], b"999");
}
}What the grid model teaches
Section titled “What the grid model teaches”Fog of war is a per-player view over shared map data. The experiment shows how a grid and per-player visibility state can be represented; a different build may pack the same relationship into different bytes.
Locate one tile
A row-major visibility map is width tiles wide. Which formula finds tile (x, y) in a flat array?
Each completed row contributes width elements. Adding x then selects the column within row y.
Finished Lesson 4.5?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.