Why Addresses Move
Understand dynamic allocation, module bases, ASLR, and why a pointer path survives when one raw address does not.
Cheatsheet for Lesson 2.7
A one-page summary of “Why Addresses Move” you can keep beside your notes. Plain Markdown.
Lesson 2.7 — Why Addresses Move: cheatsheet
- Remember: An address is only a location for this run. Identity and lifetime come from the code and objects around it.
- Also useful: Think of a tape measure: the starting mark moves each run, but a feature stays the same distance from it. Base plus offset gives today's place.
Key ideas
- adding an offset finds a slot; reading the pointer in that slot finds the next object.
Formulas and patterns
module base + relative offset = current address
Review
- Q: What location description normally survives ASLR for one exact module build? A: The current module base plus a build-specific RVA
- Q: Which pair of reasons commonly explains why a value's address differs between runs of a game? A: Memory is allocated on demand, and modules can load at different bases
The disappearing-address problem
Section titled “The disappearing-address problem”You find gold at 0x1234_5678, restart the game, and the address is different. Nothing is broken. The address was never a permanent identity.
Two common reasons are:
- dynamic allocation — the game asks for memory while it runs;
- ASLR, short for Address Space Layout Randomization — Windows loads modules at different base addresses between runs.
Virtual addresses belong to one process. The same numeric address in two different processes can name completely different physical memory, and a freed address can later be reused for another allocation in the same process.
Every address involves three separate things, location, identity, and lifetime:
- a location is the virtual address used for this access;
- an identity is the game object or field that lives there;
- a lifetime is the interval during which that mapping remains valid.
An address gives only the first item. The code that uses it, the objects that point to it, and repeated access patterns show the identity. Allocation and game events determine the lifetime. A reusable tool must rediscover or validate the location instead of treating yesterday’s address as permanent identity.
Relative locations
Section titled “Relative locations”ASLR can load a module at a different base address on another run. Within one exact executable build, a global’s relative virtual address (RVA) in the image is fixed even when the module base changes. The loader maps sections at their defined relative locations and applies relocations where needed. Thus a tool can rediscover the base and add the verified RVA; it must not assume ASLR picks a new base on every launch or that a rebuilt game keeps the same RVA:
module base + relative offset = current addressIf wesnoth.exe loads at 0x1400_0000 and a global pointer is at offset 0x01A2_B3C0, the current address is:
0x1400_0000 + 0x01A2_B3C0 = 0x15A2_B3C0usize is the integer type normally used for an address-sized number:
fn absolute_address(module_base: usize, offset: usize) -> Option<usize> { module_base.checked_add(offset)}checked_add returns None if the addition overflows instead of silently wrapping.
Pointer chains
Section titled “Pointer chains”A stable module location may hold a pointer to an object, which holds a pointer to another object, which contains gold:
Each arrow that says “read pointer” means:
- calculate an address;
- read a pointer-sized value from it;
- treat the result as the next address.
The last step may be a field address rather than another pointer.
Pointer size follows the target architecture. A 32-bit process stores a pointer in four bytes; a 64-bit process normally uses eight. Reading eight bytes from a 32-bit pointer field consumes neighboring data, while reading four from a 64-bit pointer truncates the address. The reader must use the target’s bitness, not the host’s.
At every link, distinguish three different numbers:
- the address where a pointer is stored;
- the pointer value read from that address;
- the field offset added to that pointer value.
Writing the chain as numbered equations makes accidental double-dereferences visible.
Walked with real numbers, the chain in the diagram above looks like this:
module base 0x1400_0000+ root offset 0x01A2_B3C0 -> 0x15A2_B3C0 an address holding a pointerread the pointer there -> 0x0453_1180 the player manager
0x0453_1180 + 0x18 -> 0x0453_1198 an address holding a pointerread the pointer there -> 0x0691_A200 the current player
0x0691_A200 + 0x30 -> 0x0691_A230 the address of the gold fieldread four bytes there -> 250 the value you wantedNotice the rhythm: add, read, add, read — and then at the last step, add and
stop. That final + 0x30 produces the address of gold, not a pointer to
somewhere else. Reading a pointer there instead would take the number 250,
treat it as an address, and try to read memory at 0x0000_00FA.
That is the “one dereference too many” mistake, and it has a recognizable signature: the error names a very small address. Ordinary game values — health, gold, ammunition — are small numbers, so when one is mistakenly used as an address it points at the very bottom of the address space, where nothing is mapped. An error mentioning an address below a few thousand almost always means you dereferenced a value instead of a pointer.
Predict first
Restart the game. Windows loads wesnoth.exe at 0x13A2_0000 this time, and the game allocates its manager and player objects at new heap addresses.
You walk the same path: root offset 0x01A2_B3C0, then + 0x18, then + 0x30. Where do you end up?
Answer: At this run’s gold field. The root slot moved with the module to 0x13A2_0000 + 0x01A2_B3C0 = 0x1544_B3C0, and the game stored this run’s manager address in it. Each read picks up a pointer the game wrote in this run, so the walk follows the objects to their new places.
Run 1: add, read, add, read, add
Gold is at 0x0691A230 in this run. Choose another run: the addresses change, the offsets do not.
Pointer scan results need checking
Section titled “Pointer scan results need checking”Cheat Engine can search for pointer paths automatically:

A result is not automatically correct. Validate it across several clean restarts. Prefer paths that:
- begin inside a known module;
- use a short chain;
- work on the exact documented game version;
- end at the expected value;
- fail safely when a pointer is null or unreadable.
A pointer scan finds numeric relationships that existed in its snapshots. It does not prove ownership. Large offsets, very long chains, and links through short-lived allocations are more likely to be coincidences. Normal gameplay tests—opening menus, changing maps, loading a save—exercise those ownership assumptions.
Give addresses a checked type
Section titled “Give addresses a checked type”Do not scatter raw address math around a program. Give it a type:
#[derive(Clone, Copy, Debug)]struct Address(usize);
impl Address { fn offset(self, amount: usize) -> Option<Self> { self.0.checked_add(amount).map(Self) }}This wrapper does not prove that an address is readable. It does stop us from accidentally mixing an address with an ordinary game value, and it gives one place for overflow checks.
The operating-system call that reads another process still belongs behind a carefully reviewed unsafe boundary. The rest of the pointer-chain algorithm can remain safe, ordinary code.
Walk a chain without losing the meaning
Section titled “Walk a chain without losing the meaning”Use a function whose name says whether the final offset is dereferenced:
fn walk_to_field( process: &Process, module_base: usize, root_offset: usize, pointer_offsets: &[usize], field_offset: usize,) -> anyhow::Result<usize> { let root = module_base.checked_add(root_offset) .context("root address overflowed")?; let mut object = process.read_pointer(root)?;
for &offset in pointer_offsets { anyhow::ensure!(object != 0, "null object pointer"); let link = object.checked_add(offset) .context("pointer-link address overflowed")?; object = process.read_pointer(link)?; }
anyhow::ensure!(object != 0, "null final object pointer"); object.checked_add(field_offset) .context("field address overflowed")}The final field address is returned without reading another pointer. Keeping that distinction in the function signature and variable names prevents the common “one dereference too many” mistake.
The change from loose arithmetic to a deliberate chain is easier to see as a diff:
The original helper adds the root offset, two link offsets and the field offset as if they were all distances inside one allocation. Here those arguments describe a two-link pointer path: each intermediate slot stores the address of another object.
fn read_gold( process: &Process, module_base: usize, root_offset: usize, first_link: usize, second_link: usize, field_offset: usize,) -> anyhow::Result<u32> { let gold = process.read_u32( module_base + root_offset + first_link + second_link + field_offset, )?; // Wrong: no intermediate pointer is read. Ok(gold)}Separate address arithmetic from dereferencing. At each link, calculate the slot, read the pointer stored there, and reject null; only the final field is read as gold. These offsets are inputs recovered for your own exact lab build, not universal game addresses.
Improved code
fn read_gold( process: &Process, module_base: usize, root_offset: usize, first_link: usize, second_link: usize, field_offset: usize,) -> anyhow::Result<u32> { let root = module_base.checked_add(root_offset) .context("root address overflowed")?; let mut object = process.read_pointer(root)?; for offset in [first_link, second_link] { anyhow::ensure!(object != 0, "null object pointer"); let slot = object.checked_add(offset).context("link address overflowed")?; object = process.read_pointer(slot)?; } anyhow::ensure!(object != 0, "null final object pointer"); let field = object.checked_add(field_offset).context("field address overflowed")?; let gold = process.read_u32(field)?; Ok(gold)}Show the diff
fn read_gold( process: &Process, module_base: usize, root_offset: usize, first_link: usize, second_link: usize, field_offset: usize,) -> anyhow::Result<u32> { let gold = process.read_u32( module_base + root_offset + first_link + second_link + field_offset, )?; // Wrong: no intermediate pointer is read. let root = module_base.checked_add(root_offset) .context("root address overflowed")?; let mut object = process.read_pointer(root)?; for offset in [first_link, second_link] { anyhow::ensure!(object != 0, "null object pointer"); let slot = object.checked_add(offset).context("link address overflowed")?; object = process.read_pointer(slot)?; } anyhow::ensure!(object != 0, "null final object pointer"); let field = object.checked_add(field_offset).context("field address overflowed")?; let gold = process.read_u32(field)?; Ok(gold)}Takeaway: adding an offset finds a slot; reading the pointer in that slot finds the next object.
This interactive lab needs JavaScript. The lesson text around it still explains the same idea.
Describe a moving address
What location description normally survives ASLR for one exact module build?
ASLR moves the base, while an RVA identifies a distance inside that same module build.
Finished Lesson 2.7?
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.