Skip to content
Lesson 2.10Following Instructions and Addresses

What a Working Detour Must Preserve

Build a temporary debugger-only detour, prove every hop, and restore the target.

Lesson 2.10
Read or listenPrint chapter or book
About 17 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 2.10Key ideas, reference and questions

A one-page summary of “What a Working Detour Must Preserve” you can keep beside your notes. Plain Markdown.

Lesson 2.10 — What a Working Detour Must Preserve: cheatsheet

  • Remember: The cave must leave the interrupted code as it found it: save state, run your logic, restore state, replay the removed instructions once, then jump back.
  • Also useful: Think of test-driving a new road with an empty truck first. Only when the trip works do you load the cargo, meaning the new logic.

Key terms

  • hook site: A hook site is the address where normal execution is redirected.
  • detour: A detour is that redirection.
  • code cave: A code cave is executable storage holding the added instructions.
  • return address: A return address is the first untouched instruction where normal execution resumes.

Review

  • Q: A code cave adds one instruction that changes eax and the flags, then replays the displaced instructions and jumps back. Why must it save and restore eax and the flags around its own work? A: The hooked function was mid-calculation, so later instructions may still need the values in eax and the flags
  • Q: A hook sits at 0x00A1B07C and the displaced span is 7 bytes. What is the return address? A: 0x00A1B083, since 0x7C is 124 and 124 + 7 = 131, which is 0x83
Download cheatsheet (.md)

This lesson follows the Terrain Description menu in offline, 32-bit Wesnoth 1.14.9. A six-byte instruction span in that path will become a temporary detour. Use x64dbg to trace every hop before automating its lifecycle in Lesson 8.3.

A hook site is the address where normal execution is redirected. A detour is that redirection. A code cave is executable storage holding the added instructions. A return address is the first untouched instruction where normal execution resumes.

Some tools use the word trampoline for a small block that replays displaced instructions and jumps back. Names vary, but every working detour must answer the same questions:

Where do we leave the original path?
Which complete instructions did we remove?
Where will the new code live?
What machine state must it preserve?
Where does normal execution resume?
How will every changed byte be restored?

The CPU has no concept of “our patch.” It only follows the instruction pointer. A single wrong length or destination sends that pointer into unrelated bytes.

Right-click a tile and open Terrain Description to trigger this path. It gives us an easy local-menu event without changing competitive or networked behavior.

The terrain-description function

The terrain-description function that the detour starts from.

Pause the game and choose a hook site with enough complete instructions for a jump. Building the detour takes five facts about that site. For the exact course build they are:

Hook address: 0x00CC_AF8A
Original instructions: mov eax,[ecx] / alignment lea
Original bytes: 8B 01 8D 74 26 00
Total byte length: 6
Return address: 0x00CC_AF90

The byte count comes from instruction decoding, not subtraction by guesswork. 8B 01 is the two-byte mov eax,[ecx], and 8D 74 26 00 is a four-byte lea that changes nothing and exists only as alignment padding, so the two instructions cover 2 + 4 = 6 bytes. The return address is the first byte after them: 0x00CCAF8A + 6 = 0x00CCAF90, because 0x8A is 8 × 16 + 10 = 138, and 138 + 6 = 144 = 9 × 16 = 0x90.

Do not continue until the return address points to the first untouched instruction.

x86 instructions have different lengths. A five-byte jump cannot safely replace the first five bytes of a six-byte instruction. The overwrite must end exactly on an instruction boundary, even when that means replacing six, eight, or more bytes and filling unused space with nop.

For this historical debugger exercise, locate executable padding inside the controlled module.

An empty-looking area near the end of the module

Empty-looking padding near the end of the module, where a cave can fit.

“Looks empty” is enough for this one debugger experiment, not for a repeatable hook installer. A detour installed whenever the game starts should allocate its own cave, request the needed page protection, verify the hook bytes, and release or restore everything during shutdown. Here we use existing padding so you can see the control-flow idea before building that machinery in chapter 6.

Start with no extra behavior:

; cave
; replay the complete instructions removed from the hook
jmp return_address

At the hook site, write a jump to the cave and fill any remaining whole bytes with nop.

A jump from the hook into the cave

A jump written at the hook site that leads into the cave.

Set breakpoints at:

  • the hook;
  • the first cave instruction;
  • the return address.

Trigger the menu once. You should see the breakpoints in that order. This proves the path before new logic complicates it.

The hook interrupts a function in the middle of its work. Registers, flags, and stack entries may already carry values needed by later instructions.

The cave must normally:

  1. save any register or flag it will change but the original code expects;
  2. run the added behavior;
  3. restore that machine state;
  4. replay the complete displaced instructions exactly once;
  5. jump to the first untouched instruction.

Replaying an instruction twice is just as wrong as skipping it. Changing the stack pointer without undoing the change can make a later ret jump to garbage. These delayed failures are why a hook may appear to work once and crash later.

Predict first

The game reaches the hook with ecx = 0x0A3C1000, the object that the displaced mov eax,[ecx] reads. The cave's extra logic sets ecx to 1 for its own work.

The cave forgets to restore ecx, then replays mov eax,[ecx]. What happens?

Answer: It reads from address 0x00000001 and faults. mov eax,[ecx] uses the current ecx, which is 1. Nothing is mapped at address 1, so the read faults. Saving ecx before the extra logic and restoring it afterwards would have put 0x0A3C1000 back.

One trip through a detour. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

hook:  jmp cave  ; 6 bytes: jmp + nop padding
cave:  push ecx  ; save what we will change
       mov ecx, 1  ; added behaviour borrows ecx
       pop ecx  ; restore it
       mov eax, [ecx]  ; replay displaced instruction
       lea esi, [esi+0]  ; replay displaced instruction
       jmp 0x00CCAF90  ; first untouched instruction

Jump to 0x00CCAF90, which is 0x00CCAF8A + 6, the first byte after the displaced instructions. The game continues with ecx, eax and the stack exactly as if the detour had never been there.

Final values: ip = 0x00CCAF90; ecx = 0x0A3C1000; eax = 0x00000007; stack = empty.

Read every step of the lesson’s example
  1. Step 1, line 1. The game reaches 0x00CCAF8A. The six bytes there used to be 8B 01 8D 74 26 00, which is mov eax,[ecx] (2 bytes) plus an alignment lea (4 bytes). Now they hold a jump to the cave and a nop, so the CPU goes to the cave instead.

    ip = 0x00CCAF8A; ecx = 0x0A3C1000; eax = 0x00000000 (old value); stack = empty.

    Memory: …AF8A = E9 (jmp); …AF8B = .. (to cave); …AF8C = .. (to cave); …AF8D = .. (to cave); …AF8E = .. (to cave); …AF8F = 90 (nop).

  2. Step 2, line 2. The cave first saves ecx on the stack, because the added code is about to change it and the game still needs it.

    ip = in the cave; ecx = 0x0A3C1000; eax = 0x00000000 (old value); stack = ecx (0x0A3C1000).

    Memory: …AF8A = E9 (jmp); …AF8B = .. (to cave); …AF8C = .. (to cave); …AF8D = .. (to cave); …AF8E = .. (to cave); …AF8F = 90 (nop).

  3. Step 3, line 3. The added behaviour uses ecx as scratch space and sets it to 1. This is the "something our code changes" the lesson warns about.

    ip = in the cave; ecx = 0x00000001; eax = 0x00000000 (old value); stack = ecx (0x0A3C1000).

    Memory: …AF8A = E9 (jmp); …AF8B = .. (to cave); …AF8C = .. (to cave); …AF8D = .. (to cave); …AF8E = .. (to cave); …AF8F = 90 (nop).

  4. Step 4, line 4. Restore ecx from the stack. The stack is empty again, so the game's stack pointer is back where it was.

    ip = in the cave; ecx = 0x0A3C1000; eax = 0x00000000 (old value); stack = empty.

    Memory: …AF8A = E9 (jmp); …AF8B = .. (to cave); …AF8C = .. (to cave); …AF8D = .. (to cave); …AF8E = .. (to cave); …AF8F = 90 (nop).

  5. Step 5, line 5. Replay the first displaced instruction exactly once: mov eax,[ecx] reads the value at 0x0A3C1000, which is 7, into eax. The game gets the value it would have got without the detour.

    ip = in the cave; ecx = 0x0A3C1000; eax = 0x00000007; stack = empty.

    Memory: …AF8A = E9 (jmp); …AF8B = .. (to cave); …AF8C = .. (to cave); …AF8D = .. (to cave); …AF8E = .. (to cave); …AF8F = 90 (nop).

  6. Step 6, line 6. Replay the second displaced instruction. This lea only pads alignment and changes nothing, but it must still be replayed so the byte count adds up.

    ip = in the cave; ecx = 0x0A3C1000; eax = 0x00000007; stack = empty.

    Memory: …AF8A = E9 (jmp); …AF8B = .. (to cave); …AF8C = .. (to cave); …AF8D = .. (to cave); …AF8E = .. (to cave); …AF8F = 90 (nop).

  7. Step 7, line 7. Jump to 0x00CCAF90, which is 0x00CCAF8A + 6, the first byte after the displaced instructions. The game continues with ecx, eax and the stack exactly as if the detour had never been there.

    ip = 0x00CCAF90; ecx = 0x0A3C1000; eax = 0x00000007; stack = empty.

    Memory: …AF8A = E9 (jmp); …AF8B = .. (to cave); …AF8C = .. (to cave); …AF8D = .. (to cave); …AF8E = .. (to cave); …AF8F = 90 (nop).

The cave breakpoint firing

The breakpoint firing inside the cave.

Only after the round trip works, add a tiny change such as writing a known value to the already-identified gold field.

A cave containing the test change

The cave with a tiny test change added.

Trigger once, observe once, and stop. If the wrong action triggers it, your hook is too broad or your model is wrong.

Detour symptoms

Symptom Likely place to check
Crash before cave Hook bytes or jump destination
Cave never runs Wrong trigger or hook not reached
Crash after cave Saved instruction or return address
Game acts oddly later Register, flag, or stack state was changed
Runs more than once Trigger path or re-entry assumption

Change one variable per retry.

Before closing the debugger:

  1. pause the game;
  2. restore the hook’s original bytes;
  3. remove breakpoints;
  4. verify the original behavior;
  5. restore the VM snapshot if anything is uncertain.

The lasting result is your map of the detour, not the temporary patch.

After this debugger-only round trip, Lesson 8.3 implements the same map in a DLL. It verifies the six bytes, replaces them with E9 rel32 90, runs a naked x86 cave, sets Wesnoth gold to 888, replays the removed behavior, jumps to 0x00CCAF90, and restores the bytes when disabled. The complete source is also in game_hooks.rs.

game_hooks.rs

windows-labs/src/windows_impl/ · Rust, 202 lines

Show the codeHide the code
//! Exact x86 hooks used by the version-pinned course targets.

#![cfg(target_arch = "x86")]

use std::{
    ptr,
    sync::atomic::{AtomicBool, Ordering},
};

use anyhow::{Context, Result};
use windows::Win32::UI::Input::KeyboardAndMouse::{
    INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEINPUT, SendInput,
};

use super::{LocalPatch, input_structure_size, near_jump};

const WESNOTH_PLAYER_ROOT: usize = 0x017E_ED18;
const WESNOTH_GAME_OFFSET: usize = 0x0A90;
const WESNOTH_GOLD_OFFSET: usize = 0x0004;
const WESNOTH_TERRAIN_HOOK: usize = 0x00CC_AF8A;
const WESNOTH_TERRAIN_RESUME: usize = 0x00CC_AF90;

const AC_TRIGGER_HOOK: usize = 0x0040_AD9D;
const AC_TRIGGER_ORIGINAL: usize = 0x0046_07C0;
const AC_TRIGGER_RESUME: usize = 0x0040_ADA2;
const AC_RECOIL_STORE: usize = 0x0045_BAAD;
const AC_RADAR_FILTER: usize = 0x0040_9FB3;

const URT_RENDER_HOOK: usize = 0x0052_D2FD;
const URT_RENDER_RESUME: usize = 0x0052_D303;

pub static TARGET_UNDER_CROSSHAIR: AtomicBool = AtomicBool::new(false);

/// Resolves Wesnoth's two pointers and writes the live gold field.
///
/// # Safety
/// Call only inside 32-bit Wesnoth 1.14.9 after a local match has started.
unsafe fn set_wesnoth_gold(amount: u32) -> Result<()> {
    // SAFETY: the caller verified the target build; volatile access makes the
    // actual memory operation visible at this exact point.
    let player = unsafe { ptr::read_volatile(WESNOTH_PLAYER_ROOT as *const u32) } as usize;
    anyhow::ensure!(
        player != 0,
        "start a Wesnoth match before using the terrain hook"
    );

    let side_pointer_address = player
        .checked_add(WESNOTH_GAME_OFFSET)
        .context("Wesnoth side-pointer address overflowed")?;
    // SAFETY: the verified player structure owns a 32-bit pointer at +0xA90.
    let side = unsafe { ptr::read_volatile(side_pointer_address as *const u32) } as usize;
    anyhow::ensure!(side != 0, "Wesnoth side pointer is null");

    let gold_address = side
        .checked_add(WESNOTH_GOLD_OFFSET)
        .context("Wesnoth gold address overflowed")?;
    // SAFETY: the verified side structure owns its four-byte gold field at +4.
    unsafe { ptr::write_volatile(gold_address as *mut u32, amount) };
    Ok(())
}

pub fn write_wesnoth_gold(amount: u32) -> Result<()> {
    // SAFETY: the DLL worker calls this only after matching wesnoth.exe. The
    // pointer resolver still rejects the normal "no active match" null state.
    unsafe { set_wesnoth_gold(amount) }
}

extern "C" fn wesnoth_cave_body() {
    // A naked cave cannot return `Result`, so the worker verifies the target and
    // this tiny body simply leaves the game unchanged if the match is not ready.
    // SAFETY: the cave is installed only after the exact Wesnoth hook bytes
    // match; the helper checks every nullable pointer in the live chain.
    let _ = unsafe { set_wesnoth_gold(888) };
}

#[unsafe(naked)]
unsafe extern "C" fn wesnoth_terrain_cave() {
    core::arch::naked_asm!(
        "pushfd",
        "pushad",
        "call {body}",
        "popad",
        "popfd",
        // Replay the behavior of the six bytes replaced at the hook site.
        "mov eax, dword ptr [ecx]",
        "lea esi, [esi]",
        "jmp {resume}",
        body = sym wesnoth_cave_body,
        resume = const WESNOTH_TERRAIN_RESUME,
    );
}

extern "C" fn record_crosshair_target(value: u32) {
    TARGET_UNDER_CROSSHAIR.store(value != 0, Ordering::Release);
}

#[unsafe(naked)]
unsafe extern "C" fn assaultcube_trigger_cave() {
    core::arch::naked_asm!(
        // The removed instruction was this call. Run it first so EAX contains
        // the game's real target-under-crosshair result.
        "call {original}",
        "pushfd",
        "pushad",
        "push eax",
        "call {record}",
        "add esp, 4",
        "popad",
        "popfd",
        "jmp {resume}",
        original = const AC_TRIGGER_ORIGINAL,
        record = sym record_crosshair_target,
        resume = const AC_TRIGGER_RESUME,
    );
}

#[unsafe(naked)]
unsafe extern "C" fn urban_terror_render_cave() {
    core::arch::naked_asm!(
        "pushfd",
        "pushad",
        "mov dword ptr [ebx + 4], 0x0D",
        "popad",
        "popfd",
        // Replay the complete instruction that the detour replaced.
        "mov dword ptr [0x0102AE98], ebx",
        "jmp {resume}",
        resume = const URT_RENDER_RESUME,
    );
}

fn six_byte_detour(hook: usize, cave: usize) -> Result<[u8; 6]> {
    let jump = near_jump(hook, cave)?;
    let mut replacement = [0x90_u8; 6];
    replacement[..5].copy_from_slice(&jump);
    Ok(replacement)
}

pub fn install_wesnoth_terrain_hook() -> Result<LocalPatch> {
    // mov eax,[ecx] ; lea esi,[esi+eiz]
    const ORIGINAL: [u8; 6] = [0x8B, 0x01, 0x8D, 0x74, 0x26, 0x00];
    let cave = wesnoth_terrain_cave as *const () as usize;
    let replacement = six_byte_detour(WESNOTH_TERRAIN_HOOK, cave)?;
    LocalPatch::apply(WESNOTH_TERRAIN_HOOK, &ORIGINAL, &replacement)
}

pub fn install_assaultcube_trigger_hook() -> Result<LocalPatch> {
    // call 0x004607C0, encoded relative to the end of this instruction.
    const ORIGINAL: [u8; 5] = [0xE8, 0x1E, 0x5A, 0x05, 0x00];
    let cave = assaultcube_trigger_cave as *const () as usize;
    let replacement = near_jump(AC_TRIGGER_HOOK, cave)?;
    LocalPatch::apply(AC_TRIGGER_HOOK, &ORIGINAL, &replacement)
}

pub fn install_urban_terror_memory_wallhook() -> Result<LocalPatch> {
    // mov dword ptr [0x0102AE98],ebx
    const ORIGINAL: [u8; 6] = [0x89, 0x1D, 0x98, 0xAE, 0x02, 0x01];
    let cave = urban_terror_render_cave as *const () as usize;
    let replacement = six_byte_detour(URT_RENDER_HOOK, cave)?;
    LocalPatch::apply(URT_RENDER_HOOK, &ORIGINAL, &replacement)
}

pub fn enable_assaultcube_no_recoil() -> Result<LocalPatch> {
    // fstp dword ptr [ebx+0x44] -> fstp st(0), nop
    LocalPatch::apply(AC_RECOIL_STORE, &[0xD9, 0x5B, 0x44], &[0xDD, 0xD8, 0x90])
}

pub fn enable_assaultcube_show_all_radar() -> Result<LocalPatch> {
    // 0F 85 is a six-byte near JNE. Capture its four-byte destination exactly,
    // NOP the full branch, and let LocalPatch restore all six captured bytes.
    LocalPatch::apply_masked(
        AC_RADAR_FILTER,
        &[Some(0x0F), Some(0x85), None, None, None, None],
        &[0x90; 6],
    )
}

pub fn send_left_mouse(pressed: bool) -> Result<()> {
    let flags = if pressed {
        MOUSEEVENTF_LEFTDOWN
    } else {
        MOUSEEVENTF_LEFTUP
    };
    let input = INPUT {
        r#type: INPUT_MOUSE,
        Anonymous: INPUT_0 {
            mi: MOUSEINPUT {
                dx: 0,
                dy: 0,
                mouseData: 0,
                dwFlags: flags,
                time: 0,
                dwExtraInfo: 0,
            },
        },
    };
    // SAFETY: `input` is a fully initialized INPUT value and the structure size
    // is the one expected by this build of Windows.
    let sent = unsafe { SendInput(&[input], input_structure_size()) };
    anyhow::ensure!(sent == 1, "SendInput sent {sent} of 1 mouse event");
    Ok(())
}

Chapter 3 uses the verified addresses and types from this chapter to build an external memory tool. The DLL hook remains a later application of this same control-flow model.

Knowledge check

Leave the machine as you found it

A code cave adds one instruction that changes eax and the flags, then replays the displaced instructions and jumps back. Why must it save and restore eax and the flags around its own work?

Finished Lesson 2.10?

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.

Chapter 2: 10 lessons

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.