Skip to content
Lesson 8.3In-Process Tools, Input, and Integration

Detours and Reversible Patches

Understand how a detour redirects execution, preserves displaced instructions, verifies target bytes, and restores the original path.

Lesson 8.3
Read or listenPrint chapter or book
About 44 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 8.3Key ideas, reference and questions

A one-page summary of “Detours and Reversible Patches” you can keep beside your notes. Plain Markdown.

Lesson 8.3 — Detours and Reversible Patches: cheatsheet

  • Remember: Verify restoration by reading the original instruction span again; a disabled-looking feature is not enough evidence.
  • Also useful: A patch plan is a work order, not the work itself: it describes the exact starting bytes and replacement before touching memory.

Key terms

  • PatchPlan: A PatchPlan is only that description: constructing or validating it cannot modify the game.
  • Drop: Drop is a final safety net, not the only cleanup path.
  • from: A 5-byte relative jump stores a signed 32-bit displacement measured from the end of the jump: rel32 = to − (from + 5), where from is the jump's own address and to is the cave's.
  • to: A 5-byte relative jump stores a signed 32-bit displacement measured from the end of the jump: rel32 = to − (from + 5), where from is the jump's own address and to is the cave's.
  • pushad: pushfd/pushad is easy to understand for this small 32-bit teaching cave.

Formulas and patterns

  • hook site: 0x00CC_AF8A; resume address: 0x00CC_AF90; hook span: 6 complete bytes; trigger: right-click a tile → Terrain Description; visible result: player gold becomes 888 before the text appears

Review

  • Q: Which sequence makes an in-process detour responsibly reversible? A: Verify expected bytes, save originals, write the whole patch, then restore those originals on shutdown
  • Q: Why describe a planned memory patch as plain data, with an address, expected bytes and replacement, before applying it? A: It can be checked and reviewed without touching the target, so a mistake cannot modify anything
Download cheatsheet (.md)

Typed code manages the machine-code boundary

Section titled “Typed code manages the machine-code boundary”

A hook changes running machine code, so the final write is necessarily low-level. Typed code keeps the surrounding lifecycle explicit:

  • exact ownership of original bytes;
  • checked address math;
  • supported-version checks;
  • a single apply/restore state;
  • cleanup in Drop;
  • clear errors.

The Wesnoth debugger-only detour introduced in Lesson 2.10

The debugger-only detour from Lesson 2.10, which this lesson rebuilds in a DLL.

In an offline game, the main danger is self-inflicted: patching the wrong build, splitting an instruction, losing restoration bytes, or unloading while the cave still runs. Write the safety model before writing the jump:

Question Wesnoth detour answer
What are we protecting? the original instructions and a recoverable local match
What change is allowed? one verified hook span in the exact supported build
What checks validate it? executable fingerprint, architecture, decoded whole instructions, and exact current bytes
When must it refuse? any mismatch, unreachable cave, bad length, or failed protection change
How does it recover? stop users, restore captured bytes, flush the instruction cache, restore page protection, then unload

This is a game-hacking safety contract, not an enterprise threat model. Its job is to prevent an experiment from turning uncertainty into a write.

The trusted core should stay small: target-profile selection, instruction decoding, the patch writer, and the restoration record. A menu theme, log formatter, or hotkey label should not be able to bypass byte verification. When more code can validate a patch, there are more places where a bug can create a wrong write.

Before writing to a process, describe exactly which address will change, which bytes must already be there, and which same-length bytes will replace them. A PatchPlan is only that description: constructing or validating it cannot modify the game. Equal lengths also mean the planned overwrite cannot silently spill beyond the verified instruction span:

#[derive(Debug)]
struct PatchPlan {
hook: usize,
expected: Vec<u8>,
replacement: Vec<u8>,
}
impl PatchPlan {
fn validate(&self) -> Result<(), &'static str> {
if self.expected.is_empty() {
return Err("expected bytes cannot be empty");
}
if self.expected.len() != self.replacement.len() {
return Err("replacement must preserve the hook span");
}
Ok(())
}
}
Restoration needs the bytes that were replacedThe Wesnoth 1.14.9 hook span is six bytes at 0x00CCAF8A. The jump bytes are worked out under “Trace the jump” below.
  1. 1 Verify: live bytes equal plan.expected

    Hook span at 0x00CCAF8A

    Live bytes
    8B+0
    01+1
    8D+2
    74+3
    26+4
    00+5
    plan.expected
    8B+0
    01+1
    8D+2
    74+3
    26+4
    00+5

    The six live bytes match the profile, so this is the supported build and the same-length replacement may be written.

  2. 2 Apply: keep a copy, write the jump

    Hook span at 0x00CCAF8A

    Live bytes
    E9+0
    71+1
    60+2
    53+3
    6E+4
    90+5
    AppliedPatch.original
    8B+0
    01+1
    8D+2
    74+3
    26+4
    00+5

    Execution

    Path
    jumpat the hook
    cave0x6F201000
    resume0x00CCAF90

    AppliedPatch holds the six original bytes for as long as the jump sends execution through the cave.

  3. 3 Restore from the saved copy

    Hook span at 0x00CCAF8A

    Live bytes
    8B+0
    01+1
    8D+2
    74+3
    26+4
    00+5
    AppliedPatch
    active: false

    restore writes the saved bytes back and sets active to false. The cave may be freed only once no thread can still be running in it.

The Wesnoth 1.14.9 hook span is six bytes at 0x00CCAF8A. The jump bytes are worked out under “Trace the jump” below.

The planner performs no raw memory access. A disassembler should determine the whole-instruction span; never choose it by counting five arbitrary bytes.

The plan’s expected bytes are a build identity check at the precise hook site. Copy that many live bytes, compare them before any write, and return an error if the running executable disagrees with the chosen profile:

fn verify_hook(process: &Process, plan: &PatchPlan) -> anyhow::Result<()> {
let mut found = vec![0_u8; plan.expected.len()];
process.read_exact(plan.hook, &mut found)?;
anyhow::ensure!(
found == plan.expected,
"hook bytes do not match this target profile"
);
Ok(())
}

Do not turn a mismatch into a force option:

The original helper writes the replacement immediately, then runs the feature. It neither checks the live instructions nor retains an owner that can restore them when the feature stops or returns an error. This is a teaching wrapper excerpt: AppliedPatch::apply must capture verified original bytes and install through a code-writing boundary; the buildable in-process lab below uses LocalPatch for that role.

Before: write a detour with no owned restoration
fn run_detour(process: &Process, plan: &PatchPlan) -> anyhow::Result<()> {
process.write_exact(plan.hook, &plan.replacement)?; // No check or restoration owner.
run_feature_until_stopped()?;
Ok(())
}

Validate before writing, then hold an AppliedPatch owner for the whole enabled period. Explicit restoration reports a failure; dropping the owner provides a final best-effort attempt if an earlier operation fails.

Improved code

fn run_detour(process: &Process, plan: &PatchPlan) -> anyhow::Result<()> {
plan.validate().map_err(anyhow::Error::msg)?;
verify_hook(process, plan)?;
let mut applied = AppliedPatch::apply(process, plan)?;
// Keep `applied` alive for the entire period when the detour is enabled.
run_feature_until_stopped()?;
applied.restore()?;
Ok(())
}

Show the diff

fn run_detour(process: &Process, plan: &PatchPlan) -> anyhow::Result<()> {
process.write_exact(plan.hook, &plan.replacement)?; // No check or restoration owner.
plan.validate().map_err(anyhow::Error::msg)?;
verify_hook(process, plan)?;
let mut applied = AppliedPatch::apply(process, plan)?;
// Keep `applied` alive for the entire period when the detour is enabled.
run_feature_until_stopped()?;
applied.restore()?;
Ok(())
}

Takeaway: reject mismatched instructions and keep their original bytes owned until restoration finishes.

If bytes differ, stop. Do not add a “force” switch to a beginner tool.

Once the replacement is installed, cleanup needs the original bytes even if the caller returns early. AppliedPatch owns that restoration record. An explicit restore can report an error, while Drop makes one final best-effort attempt when the guard leaves scope:

struct AppliedPatch<'a> {
process: &'a Process,
address: usize,
original: Vec<u8>,
active: bool,
}
impl AppliedPatch<'_> {
fn restore(&mut self) -> anyhow::Result<()> {
if self.active {
// 🔁 Restore bytes before clearing the flag. If the write fails,
// `active` stays true so Drop can make one final best-effort attempt.
self.process.write_exact(self.address, &self.original)?;
self.active = false;
}
Ok(())
}
}
impl Drop for AppliedPatch<'_> {
fn drop(&mut self) {
// 🧹 `restore` is idempotent: explicit cleanup sets active=false, so this
// fallback cannot accidentally write the original bytes twice.
if let Err(error) = self.restore() {
eprintln!("warning: patch restoration failed: {error:#}");
}
}
}

Drop is a final safety net, not the only cleanup path. Call restore explicitly and report failures while the program can still react.

Verified installation owns its undo bytes. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

capture expected and current byte span
if current != expected: reject
save the verified original span
install the planned replacement
restore saved bytes on exit
compare the final span with the original

Result: the byte span matches the original after success. Real code must also report restoration errors and drain in-flight execution.

Final values: match = yes; finish = success; current = 8B 01 8D 74 26 00; installed = false; owns_saved_bytes = true; result = success.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    match = yes; finish = success.

  2. Step 2, line 1. The original six-byte example is the lesson’s span; the replacement is represented symbolically.

    match = yes; finish = success; current = 8B 01 8D 74 26 00; installed = false; owns_saved_bytes = false.

  3. Step 3, line 3. Retain the verified bytes before modifying the span.

    match = yes; finish = success; current = 8B 01 8D 74 26 00; installed = false; owns_saved_bytes = true.

  4. Step 4, line 4. The applied patch owns its cleanup record.

    match = yes; finish = success; current = jump plus padding; installed = true; owns_saved_bytes = true.

  5. Step 5, line 5. Normal shutdown restores the saved span.

    match = yes; finish = success; current = 8B 01 8D 74 26 00; installed = false; owns_saved_bytes = true; result = success.

  6. Step 6, line 6. Result: the byte span matches the original after success. Real code must also report restoration errors and drain in-flight execution.

    match = yes; finish = success; current = 8B 01 8D 74 26 00; installed = false; owns_saved_bytes = true; result = success.

A 5-byte relative jump stores a signed 32-bit displacement measured from the end of the jump: rel32 = to − (from + 5), where from is the jump’s own address and to is the cave’s.

This helper turns that formula into five instruction bytes. It checks that the destination fits the jump’s signed 32-bit displacement instead of truncating an out-of-range address.

fn rel32(from: usize, to: usize) -> Option<i32> {
// 🧭 The CPU measures a near jump from the address *after* its five bytes.
let next_instruction = from.checked_add(5)?;
// 📏 Use a wider signed type for subtraction so targets below `from` work
// without unsigned wraparound; the final conversion enforces rel32 range.
let difference = (to as i128) - (next_instruction as i128);
i32::try_from(difference).ok()
}
fn near_jump(from: usize, to: usize) -> Option<[u8; 5]> {
let mut bytes = [0_u8; 5];
bytes[0] = 0xE9;
bytes[1..].copy_from_slice(&rel32(from, to)?.to_le_bytes());
Some(bytes)
}

If the destination is too far away, rel32 returns None. Do not truncate the number.

rel32 and the six hook bytesnear_jump writes E9 and rel32 = to − (from + 5); the sixth byte is a 90 (nop) filler. Change the hook or the cave address.

rel32, then the bytes

rel32(from, to)

next_instruction
0x00CCAF8A + 5 = 0x00CCAF8F
difference
0x6F201000 − 0x00CCAF8F = +1,850,957,937 (0x6E536071)
i32::try_from
Some(0x6E536071)

Six bytes written at 0x00CCAF8A

Hook span
E9jmp
71rel32
60rel32
53rel32
6Erel32
90nop

0x6F201000 − 0x00CCAF8F = +1,850,957,937 (0x6E536071). It fits in a signed 32-bit number, so near_jump writes E9 71 60 53 6E, lowest byte first, and the 90 fills the sixth byte.

The target, DLL, pointer size, instruction decoder, and calling convention must agree: a 32-bit DLL loads only into a 32-bit process, and a 64-bit DLL only into a 64-bit one. Register roles, stack-slot widths, and alignment also differ, and Lesson 8.10 shows how a displaced instruction-pointer-relative operand changes meaning when it is copied. This is why copying a detour from a video or another game version is unreliable.

Before any live patch, test:

  • rel32 with forward and backward jumps;
  • overflow rejection;
  • byte verification with a fake memory buffer;
  • applying and restoring in a practice process you wrote;
  • repeated enable/disable cycles.

After every restoration test, read the hook span again and compare it with the captured original bytes. “The feature looks disabled” does not show that the machine code returned to its starting state; comparing the bytes does.

The first end-to-end target should be a tiny program with one known function.

After the practice target works, reproduce the original Wesnoth terrain-description hook:

hook site: 0x00CC_AF8A
resume address: 0x00CC_AF90
hook span: 6 complete bytes
trigger: right-click a tile → Terrain Description
visible result: player gold becomes 888 before the text appears

At 0x00CCAF8A, the supported 1.14.9 profile expects these exact six bytes:

8B 01 8D 74 26 00

They contain the instructions represented as:

mov eax, dword ptr [ecx]
lea esi, [esi]
0x00CCAF8A8B
0x00CCAF8B01
0x00CCAF8C8D
0x00CCAF8D74
0x00CCAF8E26
0x00CCAF8F00
mov eax, dword ptr [ecx]
lea esi, [esi]
The six original bytes at the hook site. 0x00CCAF8A plus this 6-byte span lands exactly on the resume address, 0x00CCAF90.

The replacement has to fit in that same six-byte span:

0x00CCAF8AE9
rel32 to the cave, 4 bytes
0x00CCAF8F90
opcode
computed at install time
filler
The patched bytes, still six bytes long. rel32 is not written here because it depends on where the DLL happens to load; near_jump computes it once the cave's real address is known.

Suppose the DLL placed terrain_cave at 0x6F201000. Then:

displacement = 0x6F201000 - (0x00CCAF8A + 5)
= 0x6F201000 - 0x00CCAF8F
= 0x6E536071
0x00CCAF8AE9
71
60
53
6E
90
jmp
0x6E536071, little endian
nop

Two things follow from that. Because the value is relative, the same five bytes mean different destinations at different addresses, so a working jump cannot be copied to another location and still land anywhere sensible. And because the value is signed, jumping backward is perfectly ordinary: a cave at a lower address simply produces a negative displacement.

The CPU therefore goes to our DLL instead. Our cave borrows control, preserves the interrupted computation, changes gold, performs the behavior we removed, and gives control back at 0x00CCAF90. The original function never returns to the hook address, so there is no loop.

This is a tiny example of control-flow redirection, a computer-science idea used by debuggers, profilers, hot-patching systems, instrumentation tools, and game hooks.

The cave interrupts a function in the middle of its work. That function had values sitting in registers and a comparison result sitting in the flags, and it expects to find all of them untouched when execution resumes. Everything the cave does — including the ordinary Rust inside cave_body — is free to overwrite them. pushad copies all eight general-purpose registers onto the stack and pushfd copies the flags; popfd and popad restore them in the opposite order, so the interrupted function never notices that anything happened.

esp+32flags, from pushfd
esp+28eax
esp+24ecx
esp+20edx
esp+16ebx
esp+12esp as it was before pushad
esp+8ebp
esp+4esi
esp →edi
pushfd
pushad, in the order it pushes them
The cave's stack after pushfd and pushad, with esp at the bottom. popad then popfd take everything back in the opposite order.

There is no universal “save every register” rule. pushfd/pushad is easy to understand for this small 32-bit teaching cave. In a performance-sensitive hook, inspect which values are live and preserve exactly what the inserted code may change. The stack must also be balanced: every push needs a matching pop, and the called function’s convention must agree with the cave.

The cave now needs a complete owner: one function changes the gold field, one preserves and restores the interrupted CPU state, and one installs a verified, reversible jump.

This is the actual cave from the build-checked Windows project:

use std::ptr;
use anyhow::{Context, Result};
use gha_windows_labs::{LocalPatch, near_jump};
const PLAYER_ROOT: usize = 0x017E_ED18;
const GAME_OFFSET: usize = 0x0A90;
const GOLD_OFFSET: usize = 0x0004;
const HOOK: usize = 0x00CC_AF8A;
const RESUME: usize = 0x00CC_AF90;
const ORIGINAL: [u8; 6] = [0x8B, 0x01, 0x8D, 0x74, 0x26, 0x00];
/// Follow Wesnoth's real pointer path and change the live gold field.
///
/// # Safety
/// Run only inside the documented 32-bit Wesnoth build with a local match open.
unsafe fn set_gold(amount: u32) -> Result<()> {
// 🛡️ SAFETY: the supported-game profile identifies this pointer root.
let player = unsafe { ptr::read_volatile(PLAYER_ROOT as *const u32) } as usize;
anyhow::ensure!(player != 0, "start a local match first");
let side_pointer_address = player
.checked_add(GAME_OFFSET)
.context("side-pointer address overflowed")?;
// 🛡️ SAFETY: the verified player record stores a 32-bit side pointer here.
let side = unsafe {
ptr::read_volatile(side_pointer_address as *const u32)
} as usize;
anyhow::ensure!(side != 0, "current side pointer is null");
let gold_address = side
.checked_add(GOLD_OFFSET)
.context("gold address overflowed")?;
// 🛡️ SAFETY: the current side record stores its four-byte gold value at +4.
unsafe { ptr::write_volatile(gold_address as *mut u32, amount) };
Ok(())
}
extern "C" fn cave_body() {
// ⚠️ A naked assembly function cannot return Result. A missing match simply
// leaves gold unchanged; startup already verified the game executable.
let _ = unsafe { set_gold(888) };
}
#[cfg(target_arch = "x86")]
#[unsafe(naked)]
unsafe extern "C" fn terrain_cave() {
core::arch::naked_asm!(
// 📦 Save the flags and eight general-purpose 32-bit registers.
"pushfd",
"pushad",
// 🛡️ The helper performs the pointer checks and gold write.
"call {body}",
// 🔁 Put the interrupted thread back exactly as we found it.
"popad",
"popfd",
// ✅ Replay the behavior of all instructions replaced at the hook.
"mov eax, dword ptr [ecx]",
"lea esi, [esi]",
// 🧭 Continue at the first untouched Wesnoth instruction.
"jmp {resume}",
body = sym cave_body,
resume = const RESUME,
);
}
#[cfg(target_arch = "x86")]
fn install_terrain_hook() -> Result<LocalPatch> {
let cave_address = terrain_cave as *const () as usize;
let jump = near_jump(HOOK, cave_address)?;
let mut detour = [0x90_u8; 6];
detour[..5].copy_from_slice(&jump); // E9 plus signed rel32
// 🧹 LocalPatch refuses a different build, makes the code page writable,
// writes all six bytes, flushes the instruction cache, restores the page
// protection, and owns ORIGINAL so Drop can restore it later.
LocalPatch::apply(HOOK, &ORIGINAL, &detour)
}

Here is the important difference between “the address worked once” and a hook whose assumptions are visible:

The original helper follows the lab’s local player and side pointers, then writes gold using unchecked integer addition. These constants describe one verified offline build. This is unsafe code: the caller must guarantee that every addressed field stays live, initialized, aligned and accessible for the entire operation.

Before: follow unchecked local object addresses
/// # Safety
/// Call only inside the verified 32-bit build while these local fields remain
/// initialized, live, aligned and accessible. Serialize this operation with
/// game updates and guarantee exclusive writable access to the gold field.
unsafe fn set_gold(amount: u32) -> Result<()> {
// SAFETY: the caller guarantees the root slot is live and aligned.
let player = unsafe { ptr::read_volatile(PLAYER_ROOT as *const u32) } as usize;
let side_slot = player + GAME_OFFSET; // May overflow before the access.
// SAFETY: the caller must guarantee this computed local slot is valid.
let side = unsafe { *(side_slot as *const u32) } as usize;
// SAFETY: the caller must guarantee the local gold field remains writable.
unsafe { *((side + GOLD_OFFSET) as *mut u32) = amount };
Ok(())
}

Checked arithmetic and null checks reject obvious invalid addresses before access. They do not prove pointer validity, prevent concurrent game writes or make volatile access safe; the caller’s lifetime and build guarantees remain necessary.

Improved code

/// # Safety
/// Call only inside the verified 32-bit build while these local fields remain
/// initialized, live, aligned and accessible. Serialize this operation with
/// game updates and guarantee exclusive writable access to the gold field.
unsafe fn set_gold(amount: u32) -> Result<()> {
// SAFETY: the caller guarantees the root slot is live and aligned.
let player = unsafe { ptr::read_volatile(PLAYER_ROOT as *const u32) } as usize;
anyhow::ensure!(player != 0, "current player pointer is null");
let side_slot = player.checked_add(GAME_OFFSET)
.context("side-pointer address overflowed")?;
// SAFETY: the caller guarantees this local slot remains live and aligned.
let side = unsafe { ptr::read_volatile(side_slot as *const u32) } as usize;
anyhow::ensure!(side != 0, "current side pointer is null");
let gold_address = side.checked_add(GOLD_OFFSET)
.context("gold address overflowed")?;
// SAFETY: the caller guarantees exclusive writable access to this live field.
unsafe { ptr::write_volatile(gold_address as *mut u32, amount) };
Ok(())
}

Show the diff

/// # Safety
/// Call only inside the verified 32-bit build while these local fields remain
/// initialized, live, aligned and accessible. Serialize this operation with
/// game updates and guarantee exclusive writable access to the gold field.
unsafe fn set_gold(amount: u32) -> Result<()> {
// SAFETY: the caller guarantees the root slot is live and aligned.
let player = unsafe { ptr::read_volatile(PLAYER_ROOT as *const u32) } as usize;
let side_slot = player + GAME_OFFSET; // May overflow before the access.
anyhow::ensure!(player != 0, "current player pointer is null");
let side_slot = player.checked_add(GAME_OFFSET)
.context("side-pointer address overflowed")?;
// SAFETY: the caller must guarantee this computed local slot is valid.
// SAFETY: the caller guarantees this local slot remains live and aligned.
let side = unsafe { *(side_slot as *const u32) } as usize;
let side = unsafe { ptr::read_volatile(side_slot as *const u32) } as usize;
// SAFETY: the caller must guarantee the local gold field remains writable.
unsafe { *((side + GOLD_OFFSET) as *mut u32) = amount };
anyhow::ensure!(side != 0, "current side pointer is null");
let gold_address = side.checked_add(GOLD_OFFSET)
.context("gold address overflowed")?;
// SAFETY: the caller guarantees exclusive writable access to this live field.
unsafe { ptr::write_volatile(gold_address as *mut u32, amount) };
Ok(())
}

Takeaway: arithmetic checks supplement an unsafe memory contract; they cannot replace it.

The complete cave above is buildable source. The definitions of LocalPatch and near_jump, including VirtualProtect, FlushInstructionCache, and automatic restoration, are part of the project. The exact cave above lives in game_hooks.rs.

local_patch.rs

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

Show the codeHide the code
use std::{ffi::c_void, mem::size_of, ptr};

use anyhow::{Context, Result};
use windows::Win32::System::{
    Diagnostics::Debug::FlushInstructionCache,
    Memory::{PAGE_EXECUTE_READWRITE, PAGE_PROTECTION_FLAGS, VirtualProtect},
    Threading::GetCurrentProcess,
};

/// A patch applied inside the current process.
///
/// The original bytes are owned by this value. Calling `restore`, or simply
/// dropping the value, puts those bytes back.
#[derive(Debug)]
pub struct LocalPatch {
    address: usize,
    original: Vec<u8>,
    active: bool,
}

fn read_local(address: usize, count: usize) -> Result<Vec<u8>> {
    anyhow::ensure!(address != 0, "cannot read address zero");
    anyhow::ensure!(count != 0, "cannot read an empty instruction span");

    let mut bytes = vec![0_u8; count];
    // SAFETY: callers use a supported-game profile. The bytes are copied
    // immediately into owned memory and are never exposed as a borrowed slice.
    unsafe {
        ptr::copy_nonoverlapping(address as *const u8, bytes.as_mut_ptr(), count);
    }
    Ok(bytes)
}

fn write_local_code(address: usize, bytes: &[u8]) -> Result<()> {
    anyhow::ensure!(address != 0, "cannot patch address zero");
    anyhow::ensure!(!bytes.is_empty(), "cannot write an empty patch");

    let mut old_protection = PAGE_PROTECTION_FLAGS::default();
    // SAFETY: the supported-game profile supplies the live instruction range;
    // Windows validates the page and returns its previous protection.
    unsafe {
        VirtualProtect(
            address as *const c_void,
            bytes.len(),
            PAGE_EXECUTE_READWRITE,
            &mut old_protection,
        )?;
    }

    // SAFETY: VirtualProtect made the verified instruction range writable.
    unsafe {
        ptr::copy_nonoverlapping(bytes.as_ptr(), address as *mut u8, bytes.len());
        FlushInstructionCache(
            GetCurrentProcess(),
            Some(address as *const c_void),
            bytes.len(),
        )?;
    }

    let mut ignored = PAGE_PROTECTION_FLAGS::default();
    // SAFETY: restore the protection returned for the exact same range.
    unsafe {
        VirtualProtect(
            address as *const c_void,
            bytes.len(),
            old_protection,
            &mut ignored,
        )?;
    }
    Ok(())
}

impl LocalPatch {
    /// Applies a patch only when every original byte matches.
    pub fn apply(address: usize, expected: &[u8], replacement: &[u8]) -> Result<Self> {
        anyhow::ensure!(
            expected.len() == replacement.len(),
            "expected and replacement instruction spans differ"
        );
        let found = read_local(address, expected.len())?;
        anyhow::ensure!(
            found == expected,
            "bytes at {address:#010x} do not match the supported game build"
        );
        write_local_code(address, replacement)?;
        Ok(Self {
            address,
            original: found,
            active: true,
        })
    }

    /// Applies a patch with a mask. `Some(byte)` must match; `None` captures
    /// the live byte without guessing it. This is useful for a branch whose
    /// opcode is known but whose four-byte relative destination is recorded at
    /// runtime and restored exactly.
    pub fn apply_masked(
        address: usize,
        expected: &[Option<u8>],
        replacement: &[u8],
    ) -> Result<Self> {
        anyhow::ensure!(
            expected.len() == replacement.len(),
            "mask and replacement instruction spans differ"
        );
        let found = read_local(address, expected.len())?;
        for (index, (actual, wanted)) in found.iter().zip(expected).enumerate() {
            if let Some(wanted) = wanted {
                anyhow::ensure!(
                    actual == wanted,
                    "byte {index} at {address:#010x} is {actual:#04x}, expected {wanted:#04x}"
                );
            }
        }
        write_local_code(address, replacement)?;
        Ok(Self {
            address,
            original: found,
            active: true,
        })
    }

    /// Restores the captured bytes. Calling this more than once is harmless.
    pub fn restore(&mut self) -> Result<()> {
        if self.active {
            write_local_code(self.address, &self.original)
                .with_context(|| format!("could not restore patch at {:#010x}", self.address))?;
            self.active = false;
        }
        Ok(())
    }
}

impl Drop for LocalPatch {
    fn drop(&mut self) {
        if let Err(error) = self.restore() {
            eprintln!("warning: {error:#}");
        }
    }
}

/// Builds the five bytes used by an x86 near jump (`E9` plus `rel32`).
pub fn near_jump(from: usize, to: usize) -> Result<[u8; 5]> {
    relative_instruction(0xE9, from, to)
}

/// Builds the five bytes used by an x86 near call (`E8` plus `rel32`).
pub fn near_call(from: usize, to: usize) -> Result<[u8; 5]> {
    relative_instruction(0xE8, from, to)
}

fn relative_instruction(opcode: u8, from: usize, to: usize) -> Result<[u8; 5]> {
    let next = from.checked_add(5).context("jump address overflowed")?;
    let difference = (to as i128) - (next as i128);
    let displacement = i32::try_from(difference).context("jump is outside x86 rel32 range")?;
    let mut instruction = [0_u8; 5];
    instruction[0] = opcode;
    instruction[1..].copy_from_slice(&displacement.to_le_bytes());
    Ok(instruction)
}

/// Returns the size Windows expects when an [`INPUT`](windows::Win32::UI::Input::KeyboardAndMouse::INPUT) value is sent.
#[must_use]
pub fn input_structure_size() -> i32 {
    i32::try_from(size_of::<windows::Win32::UI::Input::KeyboardAndMouse::INPUT>())
        .expect("INPUT fits in an i32 on Windows")
}

#[cfg(test)]
mod tests {
    use super::{near_call, near_jump};

    #[test]
    fn near_jump_handles_forward_and_backward_destinations() {
        assert_eq!(near_jump(0x1000, 0x1010).unwrap(), [0xE9, 0x0B, 0, 0, 0]);
        assert_eq!(
            near_jump(0x1010, 0x1000).unwrap(),
            [0xE9, 0xEB, 0xFF, 0xFF, 0xFF]
        );
        assert_eq!(near_call(0x1000, 0x1010).unwrap(), [0xE8, 0x0B, 0, 0, 0]);
    }
}

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(())
}

Build it as 32-bit because the naked function uses x86 registers and the target stores four-byte pointers:

Terminal window
cd windows-labs
cargo build --release --target i686-pc-windows-msvc

Inject the resulting DLL with the course injector:

Terminal window
.\target\i686-pc-windows-msvc\release\injector.exe `
wesnoth.exe `
.\target\i686-pc-windows-msvc\release\gha_windows_labs.dll

Press F1 to install the cave. Right-click a tile and choose Terrain Description. Gold becomes 888 before the description is shown. Press F1 again to restore all six original bytes; repeat the menu action and gold should stay unchanged. Press End to stop the worker and restore any remaining patch.

Reuse the same detour for a live text field

Section titled “Reuse the same detour for a live text field”

The first hook changes a game value at a terrain-description event. The next case starts from a different observation: the game passes a live text pointer to its print function. The patch manager, checked relative jump, saved bytes, and verified restoration procedure stay the same; the cave changes what it reads and writes.

Now use the real target. On the Den of Onis map, right-click a Ford tile and open Terrain Description. Search for a distinctive part of the description, then change the first letter of each candidate until the displayed text changes. In the book’s run, the live string began at 0x10CE996B.

Set a read breakpoint on one byte of that string and open the description again. Step out until you reach 0x005ED114. Here edx leads to the text object, and the call at 0x005ED129 prints it. The original print target is 0x005E9630; execution continues at 0x005ED12E.

For the debugger-only experiment, place a cave at 0x01343E1B, replace the five-byte call at 0x005ED129 with a relative jump, and assemble:

pushad
mov eax, dword ptr [edx]
inc byte ptr [eax]
popad
call 0x005E9630
jmp 0x005ED12E

This deliberately changes the first byte each time the description is drawn: A becomes B, then C, and so on. It is visible proof that the cave receives the live text pointer and still replays the original call.

The installer reuses the checked near_jump helper from the first hook. Before writing, verify that the five original bytes still encode the expected call. When disabling the hack, restore those exact saved bytes. Close and reopen the description between tests so each character change is obvious.

Turn the text cave into the complete second-player gold patch

Section titled “Turn the text cave into the complete second-player gold patch”

The finished hook keeps the same five-byte patch point, original print call, and resume address. Instead of incrementing one letter, it follows Wesnoth’s verified in-process pointer chain and writes the second player’s gold as decimal text:

[0x017EED18] → player object
player + 0x0A90 → game/side object
game + 0x0274 → second player's gold
[EDX] → start of the live output text buffer

The helper checks every pointer, caps the displayed value at 999, converts it into at most three ASCII digits, and copies exactly three bytes into the existing buffer. The naked cave supplies the live EDX, preserves flags and registers, replays the original print call, and resumes the game:

#[unsafe(naked)]
unsafe extern "C" fn wesnoth_stat_cave() {
core::arch::naked_asm!(
"pushfd",
"pushad",
"push edx",
"call {prepend}",
"add esp, 4",
"popad",
"popfd",
"call {original}",
"jmp {resume}",
prepend = sym prepend_second_player_gold,
original = const 0x005E_9630,
resume = const 0x005E_D12E,
);
}
pub fn install_wesnoth_stat_hook() -> anyhow::Result<LocalPatch> {
let expected = near_call(0x005E_D129, 0x005E_9630)?;
let replacement = near_jump(
0x005E_D129,
wesnoth_stat_cave as *const () as usize,
)?;
LocalPatch::apply(0x005E_D129, &expected, &replacement)
}

Inject the 32-bit DLL into wesnoth.exe, open the terrain description during the two-player local scenario, and press F2. The first three characters change to the other player’s live gold. Press F2 again or End to restore the original call bytes.

The complete helper, decimal conversion, cave, and installer are in wesnoth_hooks.rs. The hotkey and patch lifetime are in dll.rs.

The first hook redirects a verified instruction span; the second redirects a verified call. Both restore exactly what they displaced. Lesson 8.4 changes a different boundary: an imported function pointer rather than instruction bytes.

To transfer either example to another build, start from the observation that led to the hook: which action reaches the instruction, and which register or pointer holds the value at that moment? Then re-measure the complete instruction span, expected bytes, cave reachability, displaced behavior, resume address, and restoration result in that build. The Wesnoth addresses above are evidence for one 32-bit version, not reusable offsets.

Knowledge check

Treat a patch as a transaction

Which sequence makes an in-process detour responsibly reversible?

Finished Lesson 8.3?

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 8: 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.