Skip to content
Lesson 4.6Game State and Bounded Decisions

Automation as a Feedback Loop

Use a recruitment loop to see why feedback, named states, confirmation, and a safe stop make automation reliable.

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

A one-page summary of “Automation as a Feedback Loop” you can keep beside your notes. Plain Markdown.

Lesson 4.6 — Automation as a Feedback Loop: cheatsheet

  • Remember: A reliable bot closes the loop: sense, act once, then check the result before acting again. A sleep only limits the rate.
  • Also useful: Think of a traffic light: it is red, amber or green, never two at once. An enum likewise represents exactly one bot state at a time.

Key terms

  • Cooldown: Acting remembers that a recruitment was requested, Cooldown stores how long to wait after a confirmed action, and Recovering carries a reason for a failed one.
  • Stopped: The expected result is Stopped, because a user stop must outrank a possible recruit action.
  • Waiting: For this macro, the start is Waiting, each snapshot allows one transition, cooldown ticks count toward zero, and the shared stop request moves the machine to Stopped.

Formulas and patterns

  • sense → validate → decide → act once → observe the outcome → correct or stop; ↑ |; └────────────────────── next fresh snapshot ────────────────────┘

Review

  • Q: Why model an offline macro as named states and guarded transitions? A: The current action, allowed next actions, and stop behavior become explicit and testable
  • Q: A simple bot presses a buy action whenever resources look high. What problem does that design have? A: It could queue several buys before the first one shows up
Download cheatsheet (.md)

In a Wyrmsun local match, a bot that presses Recruit whenever gold is high could queue several workers before the first purchase appears on screen. A useful macro needs to know whether it has requested an action, whether the game accepted it, and when to stop trying.

A reliable macro knows what state it is in, which condition allows the next step, and when to stop.

For Wyrmsun 5.0.1 in a local match, use this loop:

match and player available
enough resources
action confirmed
action failed
timer elapsed
state is valid again
stop requested
stop requested
stop requested
stop requested
stop requested
Waiting
Ready
Acting
Cooldown
Recovering
Stopped

Every arrow has a reason. That is much easier to debug than nested if statements and sleeps.

This interactive lab needs JavaScript. The lesson text around it still explains the same idea.

Find the action, then find what it requires

Section titled “Find the action, then find what it requires”

The offline Wyrmsun 5.0.1 debugger trace reaches a unit-creation call:

A recruit-unit function in the debugger

A unit-creation call reached from a recruit, in Wyrmsun's debugger trace.

Do not call an internal function based on one breakpoint. Several normal calls, watched in the debugger, show its:

  • required thread;
  • argument meaning;
  • object lifetimes;
  • return or error signal;
  • resource checks;
  • cooldown or event requirements.

An action that works once under the debugger may fail when called at the wrong time.

An open-loop macro sends inputs and assumes they worked. A reliable test bot closes the loop by observing the result:

sense → validate → decide → act once → observe the outcome → correct or stop
↑ |
└────────────────────── next fresh snapshot ────────────────────┘

These stages have different jobs:

Stage Question
Sense What does the current snapshot report?
Validate Is it complete, current, and plausible?
Decide Which single action is allowed by the policy?
Act How is that one request sent through the validated adapter?
Confirm Did game state change in the expected way?
Correct Should the bot retry, choose another state, or stop?

The sleep between ticks only limits the rate. It does not prove success. Menus, animation locks, pauses, missed input, and object replacement can all make an action request produce no outcome. Keep “requested recruit” separate from “worker count increased,” and never treat the first as proof of the second.

For testing, record snapshots and feed them through the decision function. The same sequence should produce the same decisions without launching the game.

The bot needs a state for each distinct obligation. Acting remembers that a recruitment was requested, Cooldown stores how long to wait after a confirmed action, and Recovering carries a reason for a failed one. A single enum value means the bot occupies only one of these states at a time.

#[derive(Debug)]
enum MacroState {
Waiting,
Ready,
Acting { attempt: u32 },
Cooldown { ticks_left: u32 },
Recovering { reason: String },
Stopped,
}

Impossible combinations disappear. We cannot accidentally be both Acting and Cooldown.

The game snapshot below is a copy of facts the bot has validated, not the bot’s intention. next_state receives that copy, the current macro state, and a stop request. It chooses one next state without pressing a key or writing to the game. For example, Ready with at least 100 resources becomes Acting; it does not become Cooldown until a later snapshot confirms the action.

#[derive(Debug)]
struct GameSnapshot {
match_active: bool,
player_ready: bool,
resources: u32,
action_confirmed: bool,
action_failed: bool,
state_valid: bool,
}
fn next_state(state: MacroState, game: &GameSnapshot, stop: bool) -> MacroState {
if stop {
return MacroState::Stopped;
}
if !game.match_active {
return MacroState::Waiting;
}
match state {
MacroState::Waiting if game.player_ready && game.state_valid => {
MacroState::Ready
}
MacroState::Ready if game.resources >= 100 => {
MacroState::Acting { attempt: 1 }
}
MacroState::Acting { .. } if game.action_confirmed => {
MacroState::Cooldown { ticks_left: 5 }
}
MacroState::Acting { .. } if game.action_failed => {
MacroState::Recovering { reason: "action was not accepted".into() }
}
MacroState::Cooldown { ticks_left: 0 } => MacroState::Ready,
MacroState::Cooldown { ticks_left } => {
MacroState::Cooldown { ticks_left: ticks_left - 1 }
}
MacroState::Recovering { .. } if game.state_valid && game.player_ready => {
MacroState::Ready
}
current => current,
}
}

This transition function is safe, deterministic, and easy to test without a game.

Feed the transition function a ready, active snapshot with enough resources, then set stop to true. The expected result is Stopped, because a user stop must outrank a possible recruit action. The test pins that priority without launching Wyrmsun.

#[test]
fn stops_from_any_active_state() {
let snapshot = GameSnapshot {
match_active: true,
player_ready: true,
resources: 500,
action_confirmed: false,
action_failed: false,
state_valid: true,
};
assert!(matches!(
next_state(MacroState::Ready, &snapshot, true),
MacroState::Stopped
));
}

The low-level observation and action layers may be version-specific. The state machine should not be.

Before writing loop or while, state these four facts:

  1. Start: which state exists before the first tick?
  2. Continue: what must remain true for another tick to run?
  3. Progress: what changes so the loop does not repeat the same work forever?
  4. Stop: which event ends it, even if the game is paused or closes?

For this macro, the start is Waiting, each snapshot allows one transition, cooldown ticks count toward zero, and the shared stop request moves the machine to Stopped. A fixed sleep controls how often observations occur; it is not the thing that makes the logic correct.

Trace a few ticks before connecting the action adapter:

Current state Snapshot fact Next state Side effect
Waiting match and player ready Ready none
Ready resources are 120 Acting { attempt: 1 } request one action
Acting action confirmed Cooldown { ticks_left: 5 } none
any active state stop requested Stopped begin cleanup

If a row has two unrelated actions, the state probably owns too much work. If a row has no path to Stopped, the design is not safe to run yet.

Use a bounded tick rate, not a busy loop:

loop {
let stop_requested = stop.load(std::sync::atomic::Ordering::Relaxed);
if stop_requested {
// 🛑 Make stopping a real state instead of merely falling out of a loop.
state = MacroState::Stopped;
} else {
let snapshot = observer.snapshot()?;
state = next_state(state, &snapshot, false);
}
if matches!(state, MacroState::Stopped) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}

Before unloading an in-process library, set the stop flag, wait for the worker to exit, and restore any temporary state.

Wire it to Wyrmsun 5.0.1 after the DLL lessons

Section titled “Wire it to Wyrmsun 5.0.1 after the DLL lessons”
Windows VM

You can complete the state-machine and recorded-snapshot tests above before loading code into a game. The pinned Wyrmsun port below is a deeper application to revisit after Lesson 8.3 teaches verified detours and Lesson 6.9 teaches the input worker. Its concrete addresses and hooks are retained here so the bot design can be compared with the original course’s running implementation.

This is the original course’s macro bot, rebuilt as an explicit state machine.

The original lab found these module-relative facts:

Purpose wyrmsun.exe offset
recruit-unit call target 0x2CF7
recruit-unit hook 0x223471
main-loop hook 0x385D34
main-loop call target 0xDBCA
gold-chain root 0x61A504

Wyrmsun relocates its module, so calculate module_base + offset every run. For one observed base of 0x00F40000, the recruit target became 0x00F42CF7 and the hook became 0x01163471.

The Cheat Engine gold path was:

wyrmsun.exe + 0x61A504
→ read, +0x78
→ read, +0x4
→ read, +0x8
→ read, +0x4
→ read, +0x0
→ read, +0x14 = gold

Capture one legitimate worker record at the recruit hook (ecx points to the outer pointer) into a local 0x110-byte buffer. When gold is over 3000, copy that captured record back, put the expected pointer in ecx, and call the verified recruit function from the game-loop hook. The original three overwritten recruit instructions were push ecx, mov ecx, esi, and the call; replay all eight bytes before returning.

const WORKER_RECORD_SIZE: usize = 0x110;
const RECRUIT_HOOK: usize = 0x22_3471;
const RECRUIT_FN: usize = 0x002C_F7;
const LOOP_HOOK: usize = 0x38_5D34;
const LOOP_FN: usize = 0x00_DBCA;
const GOLD_ROOT: usize = 0x61_A504;
#[derive(Clone)]
struct CapturedWorker {
bytes: [u8; WORKER_RECORD_SIZE],
source: usize,
}

Build the DLL for the game’s 32-bit architecture, inject it into your offline match, recruit one worker normally to fill the buffer, then gather more than 3000 gold. Workers should begin recruiting automatically from the selected structure. If they do not, log the calculated module addresses and verify each original byte before allowing the hooks to install.

How the two Wyrmsun caves complete the action

Section titled “How the two Wyrmsun caves complete the action”

The complete implementation is in strategy_hooks.rs. It finds the relocated module, verifies both original call sequences, installs two detours, captures the legitimate 0x110-byte record, resolves the entire gold chain, calls the real recruit function with x86 thiscall, replays the removed game-loop call, and restores both hooks.

strategy_hooks.rs

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

Show the codeHide the code
//! Complete Wyrmsun and Flare hooks ported from the original course projects.

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

use std::{
    cell::UnsafeCell,
    ffi::c_void,
    ptr,
    sync::atomic::{AtomicBool, AtomicUsize, Ordering},
    thread,
    time::Duration,
};

use anyhow::{Context, Result};
use windows::{
    Win32::{
        System::LibraryLoader::GetModuleHandleW,
        UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_END},
    },
    core::w,
};

use super::{LocalPatch, near_call, near_jump, send_left_mouse};

fn module_base(name: windows::core::PCWSTR) -> Result<usize> {
    // SAFETY: the passed names are static zero-terminated strings.
    let module = unsafe { GetModuleHandleW(name) }?;
    Ok(module.0 as usize)
}

fn five_byte_detour(hook: usize, cave: usize) -> Result<[u8; 5]> {
    near_jump(hook, cave)
}

fn key_is_down(key: i32) -> bool {
    // SAFETY: Windows accepts any virtual-key integer. The high bit means down.
    unsafe { (GetAsyncKeyState(key) as u16 & 0x8000) != 0 }
}

fn end_pressed() -> bool {
    // SAFETY: VK_END is a valid virtual-key code. The low bit is an edge.
    unsafe { GetAsyncKeyState(VK_END.0.into()) & 1 != 0 }
}

// ---------------------------------------------------------------------------
// Flare 1.12 farming bot

static FLARE_MOUSE_CALL: AtomicUsize = AtomicUsize::new(0);
static FLARE_MOUSE_RETURN: AtomicUsize = AtomicUsize::new(0);
static FLARE_PLAYER_CALL: AtomicUsize = AtomicUsize::new(0);
static FLARE_PLAYER_RETURN: AtomicUsize = AtomicUsize::new(0);
static FLARE_LOOP_CALL: AtomicUsize = AtomicUsize::new(0);
static FLARE_LOOP_RETURN: AtomicUsize = AtomicUsize::new(0);

static FLARE_MOUSE_X: AtomicUsize = AtomicUsize::new(0);
static FLARE_MOUSE_Y: AtomicUsize = AtomicUsize::new(0);
static FLARE_PLAYER_X: AtomicUsize = AtomicUsize::new(0);
static FLARE_PLAYER_Y: AtomicUsize = AtomicUsize::new(0);
static FLARE_ENEMY_X: AtomicUsize = AtomicUsize::new(0);
static FLARE_ENEMY_Y: AtomicUsize = AtomicUsize::new(0);

extern "C" fn capture_flare_mouse(ebp: usize) {
    FLARE_MOUSE_X.store(ebp.saturating_add(0x664), Ordering::Release);
    FLARE_MOUSE_Y.store(ebp.saturating_add(0x668), Ordering::Release);
}

extern "C" fn capture_flare_player(ecx: usize) {
    FLARE_PLAYER_X.store(ecx.saturating_add(0x240), Ordering::Release);
    FLARE_PLAYER_Y.store(ecx.saturating_add(0x244), Ordering::Release);
}

extern "C" fn capture_flare_enemy(ebx: usize) {
    FLARE_ENEMY_X.store(ebx.saturating_sub(4), Ordering::Release);
    FLARE_ENEMY_Y.store(ebx, Ordering::Release);
}

#[unsafe(naked)]
unsafe extern "C" fn flare_mouse_cave() {
    core::arch::naked_asm!(
        "call dword ptr [{original}]",
        "pushfd",
        "pushad",
        "push ebp",
        "call {capture}",
        "add esp, 4",
        "popad",
        "popfd",
        "jmp dword ptr [{resume}]",
        original = sym FLARE_MOUSE_CALL,
        capture = sym capture_flare_mouse,
        resume = sym FLARE_MOUSE_RETURN,
    );
}

#[unsafe(naked)]
unsafe extern "C" fn flare_player_cave() {
    core::arch::naked_asm!(
        "pushfd",
        "pushad",
        "push ecx",
        "call {capture}",
        "add esp, 4",
        "popad",
        "popfd",
        "call dword ptr [{original}]",
        "jmp dword ptr [{resume}]",
        original = sym FLARE_PLAYER_CALL,
        capture = sym capture_flare_player,
        resume = sym FLARE_PLAYER_RETURN,
    );
}

#[unsafe(naked)]
unsafe extern "C" fn flare_loop_cave() {
    core::arch::naked_asm!(
        "pushfd",
        "pushad",
        "push ebx",
        "call {capture}",
        "add esp, 4",
        "popad",
        "popfd",
        "call dword ptr [{original}]",
        "jmp dword ptr [{resume}]",
        original = sym FLARE_LOOP_CALL,
        capture = sym capture_flare_enemy,
        resume = sym FLARE_LOOP_RETURN,
    );
}

pub fn install_flare_hooks() -> Result<Vec<LocalPatch>> {
    let base = module_base(w!("flare.exe"))?;
    let profiles = [
        (
            0x0E_CBC8,
            0x05_4210,
            flare_mouse_cave as *const () as usize,
            &FLARE_MOUSE_CALL,
            &FLARE_MOUSE_RETURN,
        ),
        (
            0x00_CAC4,
            0x02_0840,
            flare_player_cave as *const () as usize,
            &FLARE_PLAYER_CALL,
            &FLARE_PLAYER_RETURN,
        ),
        (
            0x06_BA94,
            0x06_B180,
            flare_loop_cave as *const () as usize,
            &FLARE_LOOP_CALL,
            &FLARE_LOOP_RETURN,
        ),
    ];

    let mut patches = Vec::with_capacity(profiles.len());
    for (hook_offset, call_offset, cave, call_slot, return_slot) in profiles {
        let hook = base
            .checked_add(hook_offset)
            .context("Flare hook overflowed")?;
        let target = base
            .checked_add(call_offset)
            .context("Flare call overflowed")?;
        call_slot.store(target, Ordering::Release);
        return_slot.store(hook + 5, Ordering::Release);

        let expected = near_call(hook, target)?;
        let replacement = five_byte_detour(hook, cave)?;
        patches.push(LocalPatch::apply(hook, &expected, &replacement)?);
    }
    Ok(patches)
}

pub fn run_flare_farming_bot(stop: &AtomicBool) -> Result<()> {
    let patches = install_flare_hooks()?;
    let mut mouse_down = false;

    while !stop.load(Ordering::Acquire) && !end_pressed() {
        let mouse_x = FLARE_MOUSE_X.load(Ordering::Acquire);
        let mouse_y = FLARE_MOUSE_Y.load(Ordering::Acquire);
        let player_x = FLARE_PLAYER_X.load(Ordering::Acquire);
        let player_y = FLARE_PLAYER_Y.load(Ordering::Acquire);
        let enemy_x = FLARE_ENEMY_X.load(Ordering::Acquire);
        let enemy_y = FLARE_ENEMY_Y.load(Ordering::Acquire);
        let ready = [mouse_x, mouse_y, player_x, player_y, enemy_x, enemy_y]
            .iter()
            .all(|address| *address != 0)
            && player_x != enemy_x
            && player_y != enemy_y;
        let active = ready && key_is_down(b'M' as i32);

        if active {
            // SAFETY: all six addresses came from the three verified live hooks.
            let (px, py, ex, ey) = unsafe {
                (
                    ptr::read_volatile(player_x as *const f32),
                    ptr::read_volatile(player_y as *const f32),
                    ptr::read_volatile(enemy_x as *const f32),
                    ptr::read_volatile(enemy_y as *const f32),
                )
            };
            anyhow::ensure!(
                [px, py, ex, ey].iter().all(|value| value.is_finite()),
                "Flare produced a non-finite coordinate"
            );
            let target_x = if ex < px { 490_u32 } else { 560_u32 };
            let target_y = if ey < py { 270_u32 } else { 330_u32 };
            // SAFETY: the mouse hook identified two live u32 fields.
            unsafe {
                ptr::write_volatile(mouse_x as *mut u32, target_x);
                ptr::write_volatile(mouse_y as *mut u32, target_y);
            }
        }

        if active != mouse_down {
            send_left_mouse(active)?;
            mouse_down = active;
        }
        thread::sleep(Duration::from_millis(1));
    }

    if mouse_down {
        send_left_mouse(false)?;
    }
    drop(patches); // restore all three calls after the worker stops using them
    Ok(())
}

// ---------------------------------------------------------------------------
// Wyrmsun 5.0.1 macro bot

const WORKER_RECORD_SIZE: usize = 0x110;
static WYRM_BASE: AtomicUsize = AtomicUsize::new(0);
static WYRM_RECRUIT_CALL: AtomicUsize = AtomicUsize::new(0);
static WYRM_RECRUIT_RETURN: AtomicUsize = AtomicUsize::new(0);
static WYRM_LOOP_CALL: AtomicUsize = AtomicUsize::new(0);
static WYRM_LOOP_RETURN: AtomicUsize = AtomicUsize::new(0);
static WYRM_OUTER_POINTER: AtomicUsize = AtomicUsize::new(0);
static WYRM_UNIT_RECORD: AtomicUsize = AtomicUsize::new(0);
static WYRM_CAPTURED: AtomicBool = AtomicBool::new(false);

struct SharedWorkerBuffer(UnsafeCell<[u8; WORKER_RECORD_SIZE]>);
// SAFETY: Wyrmsun's two hook sites run on its main game thread. The captured
// flag uses release/acquire ordering before any consumer reads the bytes.
unsafe impl Sync for SharedWorkerBuffer {}
static WYRM_WORKER_BYTES: SharedWorkerBuffer =
    SharedWorkerBuffer(UnsafeCell::new([0_u8; WORKER_RECORD_SIZE]));

extern "C" fn capture_wyrmsun_worker(outer_pointer: usize) {
    if outer_pointer == 0 {
        return;
    }
    // SAFETY: the verified recruit hook supplies a pointer to the outer pointer.
    let record = unsafe { ptr::read_volatile(outer_pointer as *const u32) } as usize;
    if record == 0 {
        return;
    }
    // SAFETY: the original course trace established a 0x110-byte worker record.
    unsafe {
        ptr::copy_nonoverlapping(
            record as *const u8,
            (*WYRM_WORKER_BYTES.0.get()).as_mut_ptr(),
            WORKER_RECORD_SIZE,
        );
    }
    WYRM_OUTER_POINTER.store(outer_pointer, Ordering::Release);
    WYRM_UNIT_RECORD.store(record, Ordering::Release);
    WYRM_CAPTURED.store(true, Ordering::Release);
}

unsafe fn read_pointer32(address: usize) -> Option<usize> {
    if address == 0 {
        return None;
    }
    // SAFETY: caller supplies one link from the verified in-process chain.
    let value = unsafe { ptr::read_volatile(address as *const u32) } as usize;
    (value != 0).then_some(value)
}

extern "C" fn maybe_recruit_wyrmsun_worker() {
    if !WYRM_CAPTURED.load(Ordering::Acquire) {
        return;
    }
    // SAFETY: this is the verified Wyrmsun module-relative gold-root slot; the
    // helper turns an ordinary null pointer into `None`.
    let Some(mut pointer) =
        (unsafe { read_pointer32(WYRM_BASE.load(Ordering::Acquire).saturating_add(0x61_A504)) })
    else {
        return;
    };
    for offset in [0x78_usize, 0x4, 0x8, 0x4, 0x0] {
        let Some(next_address) = pointer.checked_add(offset) else {
            return;
        };
        // SAFETY: every address comes from the previous non-null link plus one
        // verified offset in the Wyrmsun 5.0.1 pointer chain.
        let Some(next) = (unsafe { read_pointer32(next_address) }) else {
            return;
        };
        pointer = next;
    }
    let Some(gold_address) = pointer.checked_add(0x14) else {
        return;
    };
    // SAFETY: the verified chain ends at the live four-byte gold field.
    let gold = unsafe { ptr::read_volatile(gold_address as *const u32) };
    if gold <= 3000 {
        return;
    }

    let record = WYRM_UNIT_RECORD.load(Ordering::Acquire);
    let outer = WYRM_OUTER_POINTER.load(Ordering::Acquire);
    let function = WYRM_RECRUIT_CALL.load(Ordering::Acquire);
    if record == 0 || outer == 0 || function == 0 {
        return;
    }
    // SAFETY: the captured destination remains the record used by the game's
    // recruit path; both hooks run on the game thread.
    unsafe {
        ptr::copy_nonoverlapping(
            (*WYRM_WORKER_BYTES.0.get()).as_ptr(),
            record as *mut u8,
            WORKER_RECORD_SIZE,
        );
    }

    // The original function receives `outer` in ECX and again as its stack
    // argument. Rust's x86 thiscall ABI expresses that exact call shape.
    type RecruitFunction = unsafe extern "thiscall" fn(*mut c_void, *mut c_void);
    // SAFETY: this address is the verified wyrmsun.exe + 0x2CF7 function.
    let recruit: RecruitFunction = unsafe { std::mem::transmute(function) };
    // SAFETY: the captured outer pointer was observed in a legitimate call.
    unsafe { recruit(outer as *mut c_void, outer as *mut c_void) };
}

#[unsafe(naked)]
unsafe extern "C" fn wyrmsun_recruit_cave() {
    core::arch::naked_asm!(
        "pushfd",
        "pushad",
        "push ecx",
        "call {capture}",
        "add esp, 4",
        "popad",
        "popfd",
        // Replay all eight original bytes semantically.
        "push ecx",
        "mov ecx, esi",
        "call dword ptr [{original}]",
        "jmp dword ptr [{resume}]",
        capture = sym capture_wyrmsun_worker,
        original = sym WYRM_RECRUIT_CALL,
        resume = sym WYRM_RECRUIT_RETURN,
    );
}

#[unsafe(naked)]
unsafe extern "C" fn wyrmsun_loop_cave() {
    core::arch::naked_asm!(
        "pushfd",
        "pushad",
        "call {maybe_recruit}",
        "popad",
        "popfd",
        // Replay the five-byte call replaced at the loop hook.
        "call dword ptr [{original}]",
        "jmp dword ptr [{resume}]",
        maybe_recruit = sym maybe_recruit_wyrmsun_worker,
        original = sym WYRM_LOOP_CALL,
        resume = sym WYRM_LOOP_RETURN,
    );
}

pub fn install_wyrmsun_hooks() -> Result<Vec<LocalPatch>> {
    let base = module_base(w!("wyrmsun.exe"))?;
    WYRM_BASE.store(base, Ordering::Release);

    let recruit_hook = base + 0x22_3471;
    let recruit_function = base + 0x00_2CF7;
    WYRM_RECRUIT_CALL.store(recruit_function, Ordering::Release);
    WYRM_RECRUIT_RETURN.store(recruit_hook + 8, Ordering::Release);
    let original_call = near_call(recruit_hook + 3, recruit_function)?;
    let mut recruit_original = [0_u8; 8];
    recruit_original[..3].copy_from_slice(&[0x51, 0x8B, 0xCE]);
    recruit_original[3..].copy_from_slice(&original_call);
    let recruit_jump = near_jump(recruit_hook, wyrmsun_recruit_cave as *const () as usize)?;
    let mut recruit_detour = [0x90_u8; 8];
    recruit_detour[..5].copy_from_slice(&recruit_jump);
    let recruit_patch = LocalPatch::apply(recruit_hook, &recruit_original, &recruit_detour)?;

    let loop_hook = base + 0x38_5D34;
    let loop_function = base + 0x00_DBCA;
    WYRM_LOOP_CALL.store(loop_function, Ordering::Release);
    WYRM_LOOP_RETURN.store(loop_hook + 5, Ordering::Release);
    let loop_original = near_call(loop_hook, loop_function)?;
    let loop_detour = near_jump(loop_hook, wyrmsun_loop_cave as *const () as usize)?;
    let loop_patch = match LocalPatch::apply(loop_hook, &loop_original, &loop_detour) {
        Ok(patch) => patch,
        Err(error) => {
            drop(recruit_patch); // restore the first hook on partial installation
            return Err(error);
        }
    };
    Ok(vec![recruit_patch, loop_patch])
}

pub fn run_wyrmsun_macro(stop: &AtomicBool) -> Result<()> {
    let patches = install_wyrmsun_hooks()?;
    while !stop.load(Ordering::Acquire) && !end_pressed() {
        thread::sleep(Duration::from_millis(20));
    }
    drop(patches);
    Ok(())
}
#[unsafe(naked)]
unsafe extern "C" fn wyrmsun_loop_cave() {
core::arch::naked_asm!(
"pushfd",
"pushad",
"call {maybe_recruit}",
"popad",
"popfd",
"call dword ptr [{original}]",
"jmp dword ptr [{resume}]",
maybe_recruit = sym maybe_recruit_wyrmsun_worker,
original = sym WYRM_LOOP_CALL,
resume = sym WYRM_LOOP_RETURN,
);
}

Inject gha_windows_labs.dll into wyrmsun.exe, recruit one worker normally, select a structure, and collect more than 3000 gold. Press End to stop and restore both hooks.

Still image accompanying Automatic worker recruitment in Wyrmsun
Automatic worker recruitment in WyrmsunSilent demonstration · 9 seconds

The original course recording shows workers being recruited as resources become available. Follow the change from enough gold to a new recruitment, then relate it to the observation, guarded action, and waiting states above.

Download Automatic worker recruitment in Wyrmsun video

The reusable skill is turning that working game hack into:

  • observable inputs;
  • named states;
  • guarded transitions;
  • a bounded action;
  • a reliable stop path.

That pattern applies to test bots, accessibility helpers, replay tools, and ordinary game AI.

Knowledge check

Make automation explainable

Why model an offline macro as named states and guarded transitions?

Finished Lesson 4.6?

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