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

In-Process DLLs and Loader Boundaries

Understand what changes when Rust code runs inside a game process and how DLL loading constrains initialization.

Lesson 8.1
Read or listenPrint chapter or book
About 20 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.1Key ideas, reference and questions

A one-page summary of “In-Process DLLs and Loader Boundaries” you can keep beside your notes. Plain Markdown.

Lesson 8.1 — In-Process DLLs and Loader Boundaries: cheatsheet

  • Remember: Sharing the game’s address space removes a copying boundary, but object lifetimes and valid function signatures still matter.
  • Also useful: Separate opening the workshop from starting its machinery: loading makes the DLL available, while the explicit start function begins its work.

Reading map

  • External versus in-process
  • Create a DLL project
  • Keep DllMain tiny
  • Export an explicit start function
  • Read known in-process data
  • Make the Wesnoth gold DLL actually change gold
  • Load it and call the exported start function
  • Do not hide the target assumptions
  • Unloading is a protocol

Review

  • Q: Why should DllMain do as little work as possible? A: Windows calls it while loader bookkeeping is locked, so complex work can deadlock
  • Q: A DLL can dereference a game address directly. Why does that not make the access automatically safe? A: The address can still be null, stale, misaligned, or wrong for this game version
Download cheatsheet (.md)

An external tool uses Windows to cross a process boundary. An in-process library is loaded into the target and shares its virtual address space.

The previous lesson reconstructed a DLL function’s contract: how arguments arrive, what the result means, and who owns pointed-to data. The preceding chapter showed how code and imports become a running image. Here we create the other side of that boundary: a Rust library loaded as a guest inside one exact game build. We will follow its loading and cleanup, learn how detours and import hooks redirect calls, and give the tool input and menus. The chapter closes by putting those pieces into an architecture with one owner for each resource and an explicit cleanup path.

The two routes reach the same bytes through different doors:

In-process library (this lesson)
External tool (separate process)
crosses a process boundary,
one call at a time
ordinary pointer read/write,
same address space
DLL code, loaded into the target
ReadProcessMemory /
WriteProcessMemory
the game's memory

That makes pointer access direct, but not automatically safe. An address discovered in a debugger can still be null, stale, misaligned, or wrong for this game version.

Sharing an address space removes one copying boundary; it does not create shared ownership. The game still owns its objects, decides when they move or disappear, and may update them from other threads. Your DLL is a guest that can observe an address only while the assumptions that justified it remain true.

An external tool receives a copy through WindowsThe tool lives in its own address space. It never touches the game’s bytes, only a copy that Windows hands over.
  1. 1 The game owns its bytes in its own address space

    Game process

    Game-owned bytes
    player data

    The game process holds the data it is using.

  2. 2 The tool asks Windows to read them

    Tool process

    Request
    read the game’s bytes

    Game process

    Game-owned bytes
    player data

    The request goes through Windows, with the game’s handle.

  3. 3 The tool receives a local copy

    Tool process

    Local copy
    copy of the player data

    Game process

    Game-owned bytes
    player data

    The copy belongs to the tool. Later changes in the game do not appear in it.

The tool lives in its own address space. It never touches the game’s bytes, only a copy that Windows hands over.

A guest DLL runs inside the game’s address spaceThe DLL shares the game’s address space, so it reads the game’s own bytes. The game still owns their lifetime.
  1. 1 The DLL is loaded into the game process

    Game process (one address space)

    Game
    game code and data
    guest DLL

    Its code now runs inside the game, next to the game’s own data.

  2. 2 It reads the game-owned bytes directly

    Game process (one address space)

    Game-owned bytes
    player data
    Guest DLL
    reads player data directly

    There is no copy and no request: the DLL follows pointers into the same memory.

  3. 3 The game still owns the bytes’ lifetime

    Game process (one address space)

    Game-owned bytes
    freed by the game
    Guest DLL
    pointer now dangles

    If the game frees them, the DLL’s pointer now points at memory that is no longer valid.

The DLL shares the game’s address space, so it reads the game’s own bytes. The game still owns their lifetime.

Before dereferencing, state the contract in four parts:

  • which exact build and game state define the layout;
  • which object owns the bytes;
  • which thread may change or destroy them;
  • how long this particular pointer remains valid.

Resolve short-lived pointers near the point of use. Copy the few fields you need into a local snapshot, validate them, then release the borrowed view of game memory.

The safe path has distinct stages instead of one giant startup function:

Code must outlive its worker. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

copy a game view into an owned snapshot
worker reads its own snapshot
request the worker to stop
join before releasing worker-owned data
unload only after worker execution ends
report the lifetime relation

Result: shutdown preserves the worker’s data and code lifetimes.

Final values: shutdown = join; game_objects = alive; worker = finished; snapshot = released; dll = unloaded; stop_requested = true; safe_order = true.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.

    shutdown = join.

  2. Step 2, line 2. The worker owns a copy, while the game still owns its original objects.

    shutdown = join; game_objects = alive; worker = running; snapshot = owned; dll = loaded.

  3. Step 3, line 3. A stop request is not yet proof that the worker finished.

    shutdown = join; game_objects = alive; worker = running; snapshot = owned; dll = loaded; stop_requested = true.

  4. Step 4, line 4. Join waits until the worker stops using its snapshot and code.

    shutdown = join; game_objects = alive; worker = finished; snapshot = owned; dll = loaded; stop_requested = true.

  5. Step 5, line 5. Release owned state and unload after the worker has finished.

    shutdown = join; game_objects = alive; worker = finished; snapshot = released; dll = unloaded; stop_requested = true; safe_order = true.

  6. Step 6, line 6. Result: shutdown preserves the worker’s data and code lifetimes.

    shutdown = join; game_objects = alive; worker = finished; snapshot = released; dll = unloaded; stop_requested = true; safe_order = true.

Watch it happenA guest DLL loads, owns work, and leaves cleanly

The course DLL moves into a target address space, finishes its small loader callback, and starts through gha_start. A valid game view is copied into the worker’s owned snapshot. Stopping joins the worker, releases owned state, and unloads the DLL while game objects remain.

Read the steps
  1. Windows maps the course DLL into the target process. The DLL now shares its address space; the game still owns its objects.
  2. The loader calls DllMain while holding the loader lock. The tiny callback does loader-safe work and returns. It does not start the worker.
  3. After loading returns, the host calls the separate gha_start export. Startup is an explicit operation, outside DllMain.
  4. Startup checks the supported build before using its layouts. A failed identity check refuses work without borrowing game objects.
  5. The worker owns its local snapshot and feature state. It copies fields from a currently valid game object, then releases the borrowed view.
  6. A stop request closes new work. The owner joins the worker before releasing anything that the worker could still use.
  7. Cleanup restores owned changes and releases the local resources. Only then can the DLL leave the process. The game objects remain.

Borrowed game objects and owned worker resources have different lifetimes. Cleanup follows those owners.

Each stage earns the capabilities used by the next one, and shutdown releases them in a controlled order.

The first artifact is a library that Windows can load, not a standalone executable with a main function. Start with Cargo’s library template; the following configuration then asks Rust to emit a C-compatible dynamic library and includes the Windows types used at its boundary:

Terminal window
cargo new --lib wesnoth_lab
cd wesnoth_lab

Configure Cargo:

[lib]
crate-type = ["cdylib"]
[dependencies]
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_System_SystemServices",
] }

cdylib tells the compiler to build a Windows DLL with a C-compatible boundary.

Windows calls DllMain while holding the loader lock. Many operations are unsafe there: starting complicated work, loading other DLLs, waiting on threads, or calling code that might do those things.

use std::ffi::c_void;
use windows::Win32::{
Foundation::{HINSTANCE, HMODULE},
System::{
LibraryLoader::DisableThreadLibraryCalls,
SystemServices::DLL_PROCESS_ATTACH,
},
};
use windows::core::BOOL;
#[unsafe(no_mangle)]
pub extern "system" fn DllMain(
instance: HINSTANCE,
reason: u32,
_reserved: *mut c_void,
) -> BOOL {
if reason == DLL_PROCESS_ATTACH {
// 🛡️ SAFETY: Windows supplied the module that it is currently attaching.
let _ = unsafe { DisableThreadLibraryCalls(HMODULE(instance.0)) };
}
BOOL::from(true)
}

Normally the compiler mangles a function’s name so it can tell apart two functions with the same short name in different modules. #[no_mangle] turns that off and exports the bare name DllMain instead. If another object linked into the same DLL also exports a symbol named DllMain, or if some other code calls this export assuming a different signature, the linker or loader cannot catch the mismatch the way Rust’s type checker normally would. Rust 2024 marks #[unsafe(no_mangle)] to make that unchecked, program-wide promise visible at the call site instead of leaving it implicit.

Let the host or lab loader call a separate function after loading:

use std::sync::OnceLock;
static STARTED: OnceLock<()> = OnceLock::new();
#[unsafe(no_mangle)]
pub extern "system" fn gha_start(_argument: *mut std::ffi::c_void) -> u32 {
if STARTED.set(()).is_err() {
return 1; // already started
}
let worker = std::thread::Builder::new()
.name("gha-tool-worker".into())
.spawn(|| {
if let Err(error) = run_lab() {
eprintln!("lab stopped: {error:#}");
}
});
if worker.is_err() {
return 2;
}
0
}

This separates loading from initialization and prevents accidental double-starts.

Wrap a verified field rather than exposing raw pointers everywhere:

#[derive(Clone, Copy)]
struct PlayerAddress(usize);
impl PlayerAddress {
/// # Safety
/// The address must refer to a live, aligned `u32` gold field in the
/// documented target build for the duration of this call.
unsafe fn read_gold(self) -> u32 {
// 🛡️ SAFETY: upheld by the caller after version and pointer-chain checks.
unsafe { (self.0 as *const u32).read() }
}
}

An even better design resolves the object each time from a module-relative path and checks readable regions before dereferencing.

Make the Wesnoth gold DLL actually change gold

Section titled “Make the Wesnoth gold DLL actually change gold”

For Wesnoth 1.14.9, 32-bit Windows, use the same chain as the external tool. Only the two offsets and the root address are fixed; the two pointer values in between are whatever Wesnoth allocated this run:

read u32
+ GAME_OFFSET (0x0A90)
read u32
+ GOLD_OFFSET (0x0004)
PLAYER_ROOT
0x017E_ED18 (fixed)
player object
game-pointer field
game object
gold field (u32, writable)

The resolver below reads both live pointer values in that order. The constants identify the known root and field offsets; the returned gold pointer belongs only to the current verified match, and the worker uses it for one write.

const PLAYER_ROOT: *const u32 = 0x017E_ED18 as *const u32;
const GAME_OFFSET: usize = 0x0A90;
const GOLD_OFFSET: usize = 0x0004;
/// Resolves the live gold pointer in the documented Wesnoth build.
///
/// # Safety
/// The caller must first verify the exact 32-bit target build and that a
/// local match is active. Each intermediate pointer must be readable.
unsafe fn wesnoth_gold_pointer() -> anyhow::Result<*mut u32> {
// 🛡️ SAFETY: target/build/state checks are the caller's responsibility.
let player = unsafe { PLAYER_ROOT.read() } as usize;
anyhow::ensure!(player != 0, "player pointer is null");
let game_pointer = player.checked_add(GAME_OFFSET)
.context("game pointer address overflowed")? as *const u32;
// 🛡️ SAFETY: the verified player object contains a 32-bit game pointer here.
let game = unsafe { game_pointer.read() } as usize;
anyhow::ensure!(game != 0, "game pointer is null");
let gold = game.checked_add(GOLD_OFFSET)
.context("gold address overflowed")? as *mut u32;
Ok(gold)
}
fn set_wesnoth_gold(amount: u32) -> anyhow::Result<()> {
// 🛡️ SAFETY: `gha_start` enables this only after the build and match checks.
let gold = unsafe { wesnoth_gold_pointer()? };
// ✅ SAFETY: the resolver returned the live, aligned four-byte gold field.
unsafe { gold.write(amount) };
Ok(())
}

Call set_wesnoth_gold(999) from the worker started by gha_start. Return to the local match and trigger a UI refresh. If no match is active, the function must return an error instead of dereferencing zero.

This is a real internal memory hack: the DLL shares Wesnoth’s address space and writes the live gold field directly. The three raw dereferences are isolated and explained instead of being scattered through the thread loop.

Load it and call the exported start function

Section titled “Load it and call the exported start function”

The matching injector performs two remote calls. First it runs LoadLibraryW in Wesnoth. Then it maps the same DLL locally without running it, finds the relative offset of gha_start, adds that offset to the DLL base returned by Wesnoth, and starts a second remote thread at that export.

That second step is important. LoadLibraryW returning means Windows finished the loader-lock portion of loading. Only then does gha_start create the worker. The complete implementation is in injector.rs, and the DLL entry points are in dll.rs.

injector.rs

windows-labs/src/bin/ · Rust, 238 lines

Show the codeHide the code
#[cfg(windows)]
mod app {
    use std::{ffi::c_void, os::windows::ffi::OsStrExt, path::Path, time::Duration};

    use anyhow::{Context, Result};
    use gha_windows_labs::{OwnedHandle, Process};
    use windows::{
        Win32::{
            Foundation::{FreeLibrary, HMODULE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT},
            System::{
                LibraryLoader::{
                    DONT_RESOLVE_DLL_REFERENCES, GetModuleHandleW, GetProcAddress, LoadLibraryExW,
                },
                Memory::{
                    MEM_COMMIT, MEM_RELEASE, MEM_RESERVE, PAGE_READWRITE, VirtualAllocEx,
                    VirtualFreeEx,
                },
                Threading::{
                    CreateRemoteThread, GetExitCodeThread, LPTHREAD_START_ROUTINE,
                    PROCESS_CREATE_THREAD, PROCESS_QUERY_INFORMATION, PROCESS_VM_OPERATION,
                    PROCESS_VM_READ, PROCESS_VM_WRITE, WaitForSingleObject,
                },
            },
        },
        core::{PCWSTR, s, w},
    };

    const ALLOWED_TARGETS: &[&str] = &[
        "wesnoth.exe",
        "ac_client.exe",
        "Quake3-UrT.exe",
        "flare.exe",
        "wyrmsun.exe",
    ];

    struct RemoteAllocation<'a> {
        process: &'a Process,
        address: *mut c_void,
    }

    impl Drop for RemoteAllocation<'_> {
        fn drop(&mut self) {
            // SAFETY: this allocation belongs to this process and is released once.
            let _ =
                unsafe { VirtualFreeEx(self.process.raw_handle(), self.address, 0, MEM_RELEASE) };
        }
    }

    struct LocalModule(HMODULE);

    impl Drop for LocalModule {
        fn drop(&mut self) {
            // SAFETY: this process owns the reference returned by LoadLibraryExW.
            let _ = unsafe { FreeLibrary(self.0) };
        }
    }

    fn wait_for_thread(thread: &OwnedHandle, description: &str) -> Result<u32> {
        let timeout_ms = u32::try_from(Duration::from_secs(10).as_millis())?;
        // SAFETY: the thread handle remains live for the wait.
        let wait = unsafe { WaitForSingleObject(thread.raw(), timeout_ms) };
        if wait == WAIT_TIMEOUT {
            anyhow::bail!("{description} did not finish within 10 seconds");
        }
        if wait == WAIT_FAILED {
            return Err(windows::core::Error::from_thread().into());
        }
        anyhow::ensure!(wait == WAIT_OBJECT_0, "unexpected wait result: {}", wait.0);

        let mut exit_code = 0_u32;
        // SAFETY: the thread has finished and the output pointer is writable.
        unsafe { GetExitCodeThread(thread.raw(), &mut exit_code) }?;
        Ok(exit_code)
    }

    fn wide_absolute_path(path: &Path) -> Result<Vec<u16>> {
        let absolute = path
            .canonicalize()
            .with_context(|| format!("cannot find DLL at {}", path.display()))?;
        let mut wide: Vec<u16> = absolute.as_os_str().encode_wide().collect();
        wide.push(0); // LoadLibraryW expects a zero-terminated UTF-16 string.
        Ok(wide)
    }

    fn as_bytes(wide: &[u16]) -> &[u8] {
        // SAFETY: a u16 slice is contiguous; its byte view is exactly twice as long.
        unsafe { std::slice::from_raw_parts(wide.as_ptr().cast(), wide.len() * 2) }
    }

    pub fn run() -> Result<()> {
        let mut arguments = std::env::args().skip(1);
        let process_name = arguments
            .next()
            .context("usage: injector <allowed-process.exe> <absolute-or-relative-dll-path>")?;
        let dll_path = arguments.next().context("missing DLL path")?;

        anyhow::ensure!(
            ALLOWED_TARGETS
                .iter()
                .any(|allowed| allowed.eq_ignore_ascii_case(&process_name)),
            "{process_name:?} is not one of the course targets"
        );

        let entry = Process::find(&process_name)?;
        let access = PROCESS_CREATE_THREAD
            | PROCESS_QUERY_INFORMATION
            | PROCESS_VM_OPERATION
            | PROCESS_VM_READ
            | PROCESS_VM_WRITE;
        let process = Process::open_with_access(entry, access)?;
        anyhow::ensure!(
            process.is_32_bit()?,
            "the course DLLs require a 32-bit target"
        );

        let wide_path = wide_absolute_path(Path::new(&dll_path))?;
        let path_bytes = as_bytes(&wide_path);

        // Reserve and commit readable/writable memory inside the game.
        // SAFETY: the process handle is live; null asks Windows to choose the address.
        let remote_address = unsafe {
            VirtualAllocEx(
                process.raw_handle(),
                None,
                path_bytes.len(),
                MEM_COMMIT | MEM_RESERVE,
                PAGE_READWRITE,
            )
        };
        anyhow::ensure!(!remote_address.is_null(), "VirtualAllocEx failed");
        let remote_path = RemoteAllocation {
            process: &process,
            address: remote_address,
        };
        process.write_exact(remote_path.address as usize, path_bytes)?;

        // kernel32 is already loaded in this injector. Resolve LoadLibraryW there.
        // For the same architecture, this system DLL export is usable by the target.
        // SAFETY: both names are static zero-terminated strings.
        let kernel32 = unsafe { GetModuleHandleW(w!("kernel32.dll")) }?;
        // SAFETY: `kernel32` is live and the exported name is valid ASCII.
        let load_library = unsafe { GetProcAddress(kernel32, s!("LoadLibraryW")) }
            .context("kernel32 did not export LoadLibraryW")?;

        // CreateRemoteThread expects the same address with the thread-procedure type.
        // On 32-bit Windows both signatures use the system ABI and one pointer argument.
        // SAFETY: this 32-bit course injector and target use the Windows system
        // ABI; LoadLibraryW accepts one pointer and its HMODULE result fits u32.
        let thread_start: LPTHREAD_START_ROUTINE = Some(unsafe {
            std::mem::transmute::<
                unsafe extern "system" fn() -> isize,
                unsafe extern "system" fn(*mut c_void) -> u32,
            >(load_library)
        });

        // SAFETY: the start address is LoadLibraryW and its argument points to the
        // zero-terminated UTF-16 path that remains allocated until the thread exits.
        let thread = unsafe {
            CreateRemoteThread(
                process.raw_handle(),
                None,
                0,
                thread_start,
                Some(remote_path.address.cast_const()),
                0,
                None,
            )
        }?;
        let thread = OwnedHandle::from_raw(thread)?;

        let module_handle = wait_for_thread(&thread, "the LoadLibraryW thread")?;
        anyhow::ensure!(module_handle != 0, "LoadLibraryW returned null");

        // Map the same DLL in this injector without running DllMain. That lets
        // us ask Windows where the exported `gha_start` function sits relative
        // to the DLL base. The relative offset is identical in the game even
        // when ASLR chooses a different base address there.
        // SAFETY: `wide_path` is still a zero-terminated UTF-16 path.
        let local_module = unsafe {
            LoadLibraryExW(
                PCWSTR(wide_path.as_ptr()),
                None,
                DONT_RESOLVE_DLL_REFERENCES,
            )
        }
        .context("could not map the DLL locally to find gha_start")?;
        let local_module = LocalModule(local_module);
        // SAFETY: the module is live and the export name is zero-terminated.
        let local_start = unsafe { GetProcAddress(local_module.0, s!("gha_start")) }
            .context("the DLL does not export gha_start")?;
        let local_start_address = local_start as *const () as usize;
        let local_base = local_module.0.0 as usize;
        let start_offset = local_start_address
            .checked_sub(local_base)
            .context("gha_start was below the local DLL base")?;
        let remote_start_address = (module_handle as usize)
            .checked_add(start_offset)
            .context("remote gha_start address overflowed")?;
        // SAFETY: this is the `gha_start` RVA in the same 32-bit DLL image.
        // SAFETY: this address is the verified gha_start RVA in the same
        // 32-bit DLL image, whose export uses LPTHREAD_START_ROUTINE's ABI.
        let remote_start: LPTHREAD_START_ROUTINE = Some(unsafe {
            std::mem::transmute::<usize, unsafe extern "system" fn(*mut c_void) -> u32>(
                remote_start_address,
            )
        });

        // SAFETY: gha_start has the documented LPTHREAD_START_ROUTINE ABI and
        // accepts a null argument. The DLL stays loaded in the target.
        let start_thread = unsafe {
            CreateRemoteThread(process.raw_handle(), None, 0, remote_start, None, 0, None)
        }?;
        let start_thread = OwnedHandle::from_raw(start_thread)?;
        let start_result = wait_for_thread(&start_thread, "the gha_start thread")?;
        anyhow::ensure!(
            start_result == 0,
            "gha_start returned error code {start_result}"
        );

        println!(
            "Injected and started {} in {} (PID {}). Module handle: {module_handle:#010x}",
            Path::new(&dll_path).display(),
            process.name(),
            process.id()
        );
        Ok(())
    }
}

#[cfg(windows)]
fn main() -> anyhow::Result<()> {
    app::run()
}

#[cfg(not(windows))]
fn main() {
    eprintln!("The injector uses Windows APIs and must be built on Windows.");
}

dll.rs

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

Show the codeHide the code
//! DLL entry points and the small hotkey worker used by the course labs.

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

use std::{
    ffi::c_void,
    path::Path,
    sync::{
        OnceLock,
        atomic::{AtomicBool, Ordering},
    },
    thread,
    time::Duration,
};

use anyhow::{Context, Result};
use windows::Win32::{
    Foundation::{HINSTANCE, HMODULE},
    System::{LibraryLoader::DisableThreadLibraryCalls, SystemServices::DLL_PROCESS_ATTACH},
    UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_END, VK_F1, VK_F2, VK_F3, VK_F4, VK_F5},
};
use windows::core::BOOL;

use super::{
    LocalPatch, OPENGL_CHAMS, OPENGL_OFF, OPENGL_WALLHACK, TARGET_UNDER_CROSSHAIR,
    assaultcube_aim_once, assaultcube_update_esp, enable_assaultcube_no_recoil,
    enable_assaultcube_show_all_radar, enable_wesnoth_map_reveal, install_assaultcube_esp_hook,
    install_assaultcube_trigger_hook, install_urban_terror_memory_wallhook,
    install_urban_terror_opengl_hook, install_wesnoth_stat_hook, install_wesnoth_terrain_hook,
    run_flare_farming_bot, run_wyrmsun_macro, send_left_mouse, set_urban_terror_opengl_mode,
    write_wesnoth_gold,
};

static STARTED: OnceLock<()> = OnceLock::new();
static STOP: AtomicBool = AtomicBool::new(false);

fn pressed_once(virtual_key: i32) -> bool {
    // SAFETY: GetAsyncKeyState accepts any virtual-key integer and returns a
    // value. The low bit means the key was pressed since the previous query.
    unsafe { GetAsyncKeyState(virtual_key) & 1 != 0 }
}

fn toggle(
    slot: &mut Option<LocalPatch>,
    install: impl FnOnce() -> Result<LocalPatch>,
) -> Result<()> {
    if let Some(mut patch) = slot.take() {
        patch.restore()?;
    } else {
        *slot = Some(install()?);
    }
    Ok(())
}

fn current_process_name() -> Result<String> {
    let executable = std::env::current_exe().context("Windows did not report the game path")?;
    let name = Path::new(&executable)
        .file_name()
        .context("the game path has no file name")?
        .to_string_lossy()
        .into_owned();
    Ok(name)
}

fn run_assaultcube() -> Result<()> {
    let mut trigger = None;
    let mut no_recoil = None;
    let mut radar = None;
    let mut esp = None;
    let mut aimbot_enabled = false;
    let mut mouse_down = false;

    while !STOP.load(Ordering::Acquire) {
        if pressed_once(VK_F1.0.into()) {
            if trigger.is_some() && mouse_down {
                send_left_mouse(false)?;
                mouse_down = false;
            }
            toggle(&mut trigger, install_assaultcube_trigger_hook)?;
            if trigger.is_none() {
                TARGET_UNDER_CROSSHAIR.store(false, Ordering::Release);
            }
        }
        if pressed_once(VK_F2.0.into()) {
            toggle(&mut no_recoil, enable_assaultcube_no_recoil)?;
        }
        if pressed_once(VK_F3.0.into()) {
            toggle(&mut radar, enable_assaultcube_show_all_radar)?;
        }
        if pressed_once(VK_F4.0.into()) {
            aimbot_enabled = !aimbot_enabled;
        }
        if pressed_once(VK_F5.0.into()) {
            toggle(&mut esp, install_assaultcube_esp_hook)?;
        }

        if aimbot_enabled {
            assaultcube_aim_once()?;
        }
        if esp.is_some() {
            assaultcube_update_esp()?;
        }

        let wants_mouse_down = trigger.is_some() && TARGET_UNDER_CROSSHAIR.load(Ordering::Acquire);
        if wants_mouse_down != mouse_down {
            send_left_mouse(wants_mouse_down)?;
            mouse_down = wants_mouse_down;
        }
        if pressed_once(VK_END.0.into()) {
            break;
        }
        thread::sleep(Duration::from_millis(8));
    }

    if mouse_down {
        send_left_mouse(false)?;
    }
    // Dropping these values restores the original instructions in reverse
    // lexical order after the worker has stopped using them.
    drop(esp);
    drop(radar);
    drop(no_recoil);
    drop(trigger);
    Ok(())
}

fn run_wesnoth() -> Result<()> {
    write_wesnoth_gold(999)?;
    let mut terrain = None;
    let mut stat = None;
    let mut map = None;

    while !STOP.load(Ordering::Acquire) {
        if pressed_once(VK_F1.0.into()) {
            toggle(&mut terrain, install_wesnoth_terrain_hook)?;
        }
        if pressed_once(VK_F2.0.into()) {
            toggle(&mut stat, install_wesnoth_stat_hook)?;
        }
        if pressed_once(VK_F3.0.into()) {
            toggle(&mut map, enable_wesnoth_map_reveal)?;
        }
        if pressed_once(VK_END.0.into()) {
            break;
        }
        thread::sleep(Duration::from_millis(20));
    }
    drop(map);
    drop(stat);
    drop(terrain);
    Ok(())
}

fn run_urban_terror() -> Result<()> {
    let mut memory_wallhook = None;
    let mut opengl_hook = None;
    let mut opengl_mode = OPENGL_OFF;

    while !STOP.load(Ordering::Acquire) {
        if pressed_once(VK_F1.0.into()) {
            toggle(&mut memory_wallhook, install_urban_terror_memory_wallhook)?;
        }
        if pressed_once(VK_F2.0.into()) {
            if opengl_mode == OPENGL_WALLHACK {
                set_urban_terror_opengl_mode(OPENGL_OFF)?;
                drop(opengl_hook.take());
                opengl_mode = OPENGL_OFF;
            } else {
                if opengl_hook.is_none() {
                    opengl_hook = Some(install_urban_terror_opengl_hook(OPENGL_WALLHACK)?);
                }
                set_urban_terror_opengl_mode(OPENGL_WALLHACK)?;
                opengl_mode = OPENGL_WALLHACK;
            }
        }
        if pressed_once(VK_F3.0.into()) {
            if opengl_mode == OPENGL_CHAMS {
                set_urban_terror_opengl_mode(OPENGL_OFF)?;
                drop(opengl_hook.take());
                opengl_mode = OPENGL_OFF;
            } else {
                if opengl_hook.is_none() {
                    opengl_hook = Some(install_urban_terror_opengl_hook(OPENGL_CHAMS)?);
                }
                set_urban_terror_opengl_mode(OPENGL_CHAMS)?;
                opengl_mode = OPENGL_CHAMS;
            }
        }
        if pressed_once(VK_END.0.into()) {
            break;
        }
        thread::sleep(Duration::from_millis(8));
    }

    set_urban_terror_opengl_mode(OPENGL_OFF)?;
    drop(opengl_hook);
    drop(memory_wallhook);
    Ok(())
}

fn run_lab() -> Result<()> {
    let process = current_process_name()?.to_ascii_lowercase();
    match process.as_str() {
        "wesnoth.exe" => run_wesnoth(),
        "ac_client.exe" => run_assaultcube(),
        "quake3-urt.exe" => run_urban_terror(),
        "flare.exe" => run_flare_farming_bot(&STOP),
        "wyrmsun.exe" => run_wyrmsun_macro(&STOP),
        _ => anyhow::bail!("{process:?} is not a configured course target"),
    }
}

/// Windows calls this under the loader lock. Do only the one documented,
/// loader-safe optimization here; the injector calls `gha_start` afterward.
#[unsafe(no_mangle)]
pub extern "system" fn DllMain(instance: HINSTANCE, reason: u32, _reserved: *mut c_void) -> BOOL {
    if reason == DLL_PROCESS_ATTACH {
        // SAFETY: `instance` is the module Windows is currently attaching.
        let _ = unsafe { DisableThreadLibraryCalls(HMODULE(instance.0)) };
    }
    BOOL::from(true)
}

/// Exported with the Windows thread-procedure signature so the injector can
/// call it safely after LoadLibraryW has returned and released the loader lock.
#[unsafe(no_mangle)]
pub extern "system" fn gha_start(_argument: *mut c_void) -> u32 {
    if STARTED.set(()).is_err() {
        return 1; // already running
    }
    STOP.store(false, Ordering::Release);
    let started = thread::Builder::new()
        .name("gha-tool-worker".into())
        .spawn(|| {
            if let Err(error) = run_lab() {
                eprintln!("game lab stopped: {error:#}");
            }
        });
    if started.is_err() {
        return 2;
    }
    0
}

#[unsafe(no_mangle)]
pub extern "system" fn gha_stop(_argument: *mut c_void) -> u32 {
    STOP.store(true, Ordering::Release);
    0
}
Terminal window
cd windows-labs
cargo build --release --target i686-pc-windows-msvc
.\target\i686-pc-windows-msvc\release\injector.exe `
wesnoth.exe `
.\target\i686-pc-windows-msvc\release\gha_windows_labs.dll

With a local match already active, the worker resolves the chain and writes 999. This is the proof for the lesson: the injected library has executed inside Wesnoth and changed a live, spendable game value.

Put version-specific facts together:

struct TargetProfile {
module_name: &'static str,
build_id: &'static str,
player_path: &'static [usize],
gold_offset: usize,
}

At startup:

  1. confirm the module exists;
  2. confirm the supported build fingerprint;
  3. resolve the pointer path;
  4. check each pointer;
  5. only then enable the lab feature.

Background threads must stop before the DLL unloads. Use an atomic stop flag or a channel, wait for workers to exit from outside DllMain, restore patches, and then unload.

The important lesson is architectural: loading, running, stopping, and unloading are separate states. Model them deliberately instead of relying on global booleans and timing luck.

enum LabState {
Loaded,
Running,
Stopping,
Stopped,
}
gha_start spawns the worker
stop requested
workers joined, patches restored
Loaded
Running
Stopping
Stopped

The type system cannot make an unknown game address safe, but it can make the rest of the lifecycle much harder to mix up.

Lifecycle states should control capabilities. Loaded may allow only bookkeeping, Running may own workers and patches, Stopping may request cleanup but reject new work, and Stopped must own nothing that points into executable code. This is stronger than a single started: bool, which cannot distinguish “never started” from “cleanup still in progress.”

Knowledge check

Respect loader lock

Why should DllMain do as little work as possible?

Finished Lesson 8.1?

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.