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

Tool Architecture, Commands, and Cleanup

Organize a local tool around owned snapshots, typed commands, feature lifecycles, version checks, and dependable cleanup.

Lesson 8.9
Read or listenPrint chapter or book
About 26 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.9Key ideas, reference and questions

A one-page summary of “Tool Architecture, Commands, and Cleanup” you can keep beside your notes. Plain Markdown.

Lesson 8.9 — Tool Architecture, Commands, and Cleanup: cheatsheet

  • Remember: Let one worker change settings from queued commands, so input handlers cannot alter a feature halfway through its update.
  • Also useful: A display enum is like a radio-button group: exactly one mode is selected. Independent features behave like separate checkboxes.

Key terms

  • cohesive: A module is cohesive when its pieces belong to the same job.
  • coupled: Modules are coupled when one needs to know another's internal details.
  • Signatures: The excerpt's ModuleOffsets, Layouts, and Signatures are the project's records for module-relative locations, field layouts, and expected instruction bytes.
  • multihack: Combining separate features into one DLL is what the original course called the multihack.

Review

  • Q: A feature fails during update. The owner logs the error and runs cleanup. What else stops the feature from running again on the next update? A: Mark its slot disabled, so the update loop skips it while other features keep running
  • Q: What does it mean for a module to be cohesive? A: Its pieces all serve one job, and related work lives together
Download cheatsheet (.md)

Several small labs can work on their own and still fail when combined. The input thread changes settings while a worker is reading them. Two features install a patch at the same address. Closing a menu leaves a worker running inside a DLL that is about to unload. These failures come from the connections between components, even when each component’s main calculation is correct.

The DLL, hook, input, and menu lessons have supplied the pieces. This lesson gives each piece an owner and a place in the tool’s control flow. We will use a small monitor of a local player’s health and gold to express the design; the same separation of ownership, observations, and commands later supports camera snapshots and rendering features.

Five prototypes can each work alone and still fail when combined. Common causes include:

  • each feature reads the same memory separately;
  • two features try to own the same patch;
  • global booleans get out of sync;
  • shutdown happens while a worker is still running;
  • one feature’s error crashes every other feature.

Adding conditions to each feature does not settle who owns shared resources. An explicit architecture does: it names the stages, the data crossing between them, and the component responsible for cleanup.

A module is cohesive when its pieces belong to the same job. Modules are coupled when one needs to know another’s internal details. In plain English: keep related work together, and keep the number of secret dependencies small.

One useful project split is:

target.rs identify the executable, build, modules, and supported addresses
observe.rs turn verified Win32 reads into local snapshots
decide.rs compute alarms and feature decisions from a snapshot
apply.rs own guarded writes, patches, hooks, and restoration
ui.rs turn user events into commands and display acknowledgements
main.rs connect those pieces and control their lifetime

Each file has one main reason to change. A new game build mostly changes target.rs. A redesigned menu mostly changes ui.rs. An additional check before restoring changed bytes belongs in apply.rs, not in every checkbox handler.

This board needs JavaScript to move things around. Here is the lesson’s sorting:

observe.rs
  • Copy health and gold out of the live game object. An observer resolves live game objects and copies the few fields it needs, so nothing else borrows pointers into the game.
  • Check the copied values are in range. The observer checks ranges while it copies, then hands back ordinary Rust values that later stages can trust.
  • Return a ToolSnapshot of plain numbers. The snapshot is the output of observing. Once it exists its fields stay valid even if the game destroys the object they came from.
decide.rs
  • A rule: "given this snapshot, what does the feature propose?". A pure rule takes a snapshot and returns a proposal. It makes no Windows calls, so you can test it without launching the game.
  • Choose what each enabled feature proposes. The loop asks decide.rs what each enabled feature proposes, from the one validated snapshot.
apply.rs
  • Compare live bytes with the expected original instruction before patching. Guarded writes belong to apply.rs. A changed build must produce a clear rejection, not a plausible value from the wrong place.
  • Write the patch. Only the apply stage owns guarded writes, patches and hooks, so a safer restoration rule is changed in one file.
  • Restore the original bytes on shutdown. Whoever makes a change must be able to undo it. Restoration lives with the code that wrote the patch, not in every checkbox handler.

The main path should show the tool’s control flow clearly:

loop while running
target or tool stops
verify the exact target build
(target.rs)
start the observer and
command sources
create one validated
snapshot (observe.rs)
decide what each enabled
feature proposes (decide.rs)
apply only proposals allowed
by the verified profile (apply.rs)
stop input, join workers,
restore changes, close handles

That outline is intentionally free of addresses and Windows calls. Those facts live behind smaller interfaces, so you can review the tool’s overall logic without mentally executing every unsafe operation.

Observe once, then decide from owned values

Section titled “Observe once, then decide from owned values”

An observer captures the few fields this monitor needs. It resolves live game objects, copies the fields, checks their ranges, and returns ordinary Rust values. Other features receive this local value rather than borrowing pointers into the game:

#[derive(Clone, Copy, Debug)]
struct ToolSnapshot {
capture_id: u64, // a local identifier for this observation pass
health: u32,
gold: u32,
}

capture_id identifies a tool observation; it does not claim that the game stopped updating while those two fields were read. If the target supplies a tick or generation counter, the observer can check it before and after the copy and retry when it changes. Without that mechanism, validate what was captured and describe its limits honestly.

A health display and a gold-change logger can now consume the same values. If each reads independently, they may describe different moments. Copying also separates lifetimes: once the snapshot exists, its fields remain valid even if the game destroys the object from which they came.

A value logger and a guarded patch do different work, but both must answer the same lifecycle questions: when is the feature enabled, what does it do with this observation, and how does it undo its own changes? The interface below makes those moments explicit:

trait Feature {
fn name(&self) -> &'static str;
fn enable(&mut self) -> anyhow::Result<()>;
fn update(&mut self, frame: &ToolSnapshot) -> anyhow::Result<()>;
fn disable(&mut self) -> anyhow::Result<()>;
}

Only patching or hooking features need setup or restoration in enable and disable. A pure observer still implements those required methods, but they can simply return Ok(()). Its useful work lives in update. Returning a Result lets the owner distinguish a successful toggle from a failed one.

Three booleans named show_health, show_gold, and show_both could all become true after several clicks. Yet the display should select one mode. Store that choice as an enum alongside independent switches:

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DisplayMode {
Off,
Health,
Gold,
Both,
}
#[derive(Debug)]
struct Settings {
display: DisplayMode,
watch_health: bool,
log_changes: bool,
}

One DisplayMode value cannot hold two variants at once. The independent watch_health and log_changes switches may both be enabled because those activities can run together. The enum permits only one display mode at a time, while separate switches permit both activities. Each handler receives a state the types can represent without contradictory display modes.

A key press should request a change, not let the input thread reach into a feature while the worker is using it. The input side sends a small command; the worker applies it to its own settings:

enum Command {
ToggleHealthWatch,
CycleDisplay,
ToggleLogging,
Stop,
}
// Called by the worker; true requests an orderly stop after this command.
fn apply_command(settings: &mut Settings, command: Command) -> bool {
match command {
Command::ToggleHealthWatch => {
settings.watch_health = !settings.watch_health;
}
Command::CycleDisplay => {
settings.display = match settings.display {
DisplayMode::Off => DisplayMode::Health,
DisplayMode::Health => DisplayMode::Gold,
DisplayMode::Gold => DisplayMode::Both,
DisplayMode::Both => DisplayMode::Off,
};
}
Command::ToggleLogging => settings.log_changes = !settings.log_changes,
Command::Stop => return true,
}
false
}

CycleDisplay advances one variant at a time and wraps back to Off. Stop returns true, which the worker uses to leave its update loop and start cleanup. A stop command is a lifecycle decision; changing the label on a menu cannot stop a thread.

The input thread sends commands through a channel. The worker owns and changes the settings, avoiding shared mutable globals. If a command enables a patch, the worker sends an acknowledgement only after the patch owner reports success. The menu can then distinguish requested state from applied state.

A malformed gold observation may stop the gold display while a valid health display can continue. A closed target invalidates every feature. Decide whether an error belongs to one feature or to the shared observation before choosing what to stop.

Keep an enabled flag beside each feature. This update helper disables a failing feature, so the next pass cannot silently run it again:

use anyhow::Context;
struct FeatureSlot {
feature: Box<dyn Feature>,
enabled: bool,
}
fn update_features(
features: &mut [FeatureSlot],
snapshot: &ToolSnapshot,
) -> anyhow::Result<()> {
for slot in features.iter_mut().filter(|slot| slot.enabled) {
if let Err(error) = slot.feature.update(snapshot) {
eprintln!("{} disabled: {error:#}", slot.feature.name());
slot.enabled = false;
// A cleanup failure is not safe to hide behind a disabled checkbox.
slot.feature.disable().with_context(|| {
format!("{} could not restore its changes", slot.feature.name())
})?;
}
}
Ok(())
}

Box<dyn Feature> owns an implementation behind the common interface. The loop skips disabled slots. An update error is logged and the slot is disabled before restoration starts. If restoration succeeds, other features continue; if it fails, ? returns that error to the owner, which enters the tool’s shutdown path. The owner must retain resources needed to retry cleanup rather than unload code that may still be reachable through a hook.

Release each part after its last user stopsThe owner stops new work, waits for the worker, then restores the game and releases the DLL last.
  1. Control Stop accepting commands

    Step 1: Owner (main.rs) to Input thread

    No new command can start work that would need the hooks or the handles.

  2. Control Signal: stop after this update

    Step 2: Owner (main.rs) to Update worker

    The worker finishes the snapshot it is on instead of being cut off halfway through a write.

  3. Reply join returns: worker finished

    Step 3: Update worker to Owner (main.rs)

    Now no thread is running feature code or passing through the patches.

  4. Data Disable features: restore bytes

    Step 4: Owner (main.rs) to Game process

    Each feature’s disable writes back the bytes its patch saved; a failure is reported, not hidden.

  5. Alone Close handles, then unload

    Step 5: Owner (main.rs) (alone)

    The DLL’s code is released last, after its last user has gone.

The owner stops new work, waits for the worker, then restores the game and releases the DLL last.

Unloading while a worker still runsThe same parts in the wrong order: the DLL is released while a thread can still execute its code.
  1. Data Restore bytes, unload the DLL

    Step 1: Owner (main.rs) to Game process

    The owner skipped stopping and joining the worker.

  2. Alone Next loop iteration

    Step 2: Update worker (alone)

    The worker thread still exists, and its next instruction is in the DLL’s code pages.

  3. Control Runs released code: crash

    Step 3: Update worker to Game process

    Those pages went away with the DLL, so the game process stops with an access violation.

The same parts in the wrong order: the DLL is released while a thread can still execute its code.

Do not unload code while another thread may still execute it. Stop accepting new commands first, request worker shutdown, and wait for those workers to finish before restoring their patches and releasing handles. A restoration failure must remain visible; a vanished window is not proof that cleanup completed.

An offset is a fact about one supported game build, not a universal property of a health or gold feature. Put build identity and discovered locations together in a profile, so changing the supported build does not silently change feature logic:

struct TargetProfile {
build_id: String,
modules: ModuleOffsets,
layouts: Layouts,
signatures: Signatures,
}

The excerpt’s ModuleOffsets, Layouts, and Signatures are the project’s records for module-relative locations, field layouts, and expected instruction bytes. Their exact fields depend on the supported target. Keep those facts together instead of scattering them through display and input code.

Load one profile only after fingerprinting the executable. At every address that will be patched, compare the live bytes with the expected original instruction signature before enabling the feature. A changed build must produce a clear rejection, not a plausible-looking value from the wrong field.

Reusing proven work is normal engineering. Rebuilding everything wastes time; adding a dependency for every five-line problem creates a different kind of fragility. Make the decision in this order:

  1. Can the standard library or an existing project abstraction do it clearly?
  2. Is there a maintained crate whose API, license, and safety model fit?
  3. Is there a small licensed example you can adapt and test?
  4. If not, what is the smallest implementation you can own confidently?

When a dependency is large, isolate it behind a small interface. When copied functionality needs heavy modification in several places, either make one reusable project module or choose a library that already owns that problem. When configuration changes more often than low-level mechanics, a scripting layer such as Lua may be a better boundary than recompiling the whole tool.

Reading other projects shows how several tools model the same problem. Test their assumptions against your target, then write the smallest design that fits. When code is actually reused, preserve its required license notices and attribution; when only an idea is reused, explain it in your own structure and words.

Porting a small licensed tool can be good practice. Begin with its tests or observable behavior, reproduce one behavior at a time, and explain why your ownership and error model differ. This teaches the boundary instead of rewarding blind copy-and-paste.

Test patch decisions without launching the game

Section titled “Test patch decisions without launching the game”

Do not make every test launch AssaultCube and call ReadProcessMemory. Put a small trait between the instruction-byte comparison and the Windows memory-read API. The real tool uses a Process-backed reader; a fast test uses bytes stored inside the test:

trait ByteReader {
fn read_bytes(&self, address: usize, count: usize) -> anyhow::Result<Vec<u8>>;
}
fn matches_supported_build(
reader: &impl ByteReader,
address: usize,
expected: &[u8],
) -> anyhow::Result<bool> {
Ok(reader.read_bytes(address, expected.len())? == expected)
}
struct FakeReader(Vec<u8>);
impl ByteReader for FakeReader {
fn read_bytes(&self, _address: usize, count: usize) -> anyhow::Result<Vec<u8>> {
Ok(self.0.iter().copied().take(count).collect())
}
}
#[test]
fn changed_instruction_bytes_reject_the_profile() {
let reader = FakeReader(vec![0x90, 0x90, 0x90]);
let accepted = matches_supported_build(&reader, 0x0040_AD9D, &[0x29, 0x42, 0x04])
.expect("the in-memory reader cannot fail");
assert!(!accepted);
}

This test answers a game-hacking question: “Does the tool refuse to patch when the live instruction bytes do not match the supported build?” It does not need a running match to answer that. Separate Windows-only checks can verify handle ownership and byte copying. When one fails, you know whether the problem is the comparison with the supported build or the ReadProcessMemory call. 🧪

A one-shot memory scanner can print its matches directly. A long-running in-process menu should log the same fields for every state change: target_game, target_build, feature, address, expected_bytes, found_bytes, action, and restored. Those log entries show which patch was enabled and whether its original bytes were restored. Keep them separate from the visible menu so closing or redesigning the UI does not erase the log needed to debug a failed cleanup.

Combining separate features into one DLL is what the original course called the multihack. The useful engineering question is whether the combination preserves each feature’s resource owner and completes its shutdown and restoration.

A multifeature tool is ready when:

  • starting it twice does not double-hook;
  • every feature can be toggled repeatedly;
  • disabling restores its changes;
  • target closure stops the tool cleanly;
  • unknown versions are rejected;
  • one feature error does not corrupt the rest;
  • the offline target behaves normally after exit.

The next hook lesson examines the live control-flow changes that this owner must verify and restore. Chapter 7 then applies the same architecture to rendering: the camera, player positions, and viewport must belong to the same captured update, and every draw must leave the game’s graphics state intact.

Knowledge check

Make failure isolation last

A feature fails during update. The owner logs the error and runs cleanup. What else stops the feature from running again on the next update?

Finished Lesson 8.9?

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.