Tool Architecture, Commands, and Cleanup
Organize a local tool around owned snapshots, typed commands, feature lifecycles, version checks, and dependable cleanup.
Cheatsheet for Lesson 8.9
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'sModuleOffsets,Layouts, andSignaturesare 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
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.
Combining code exposes hidden problems
Section titled “Combining code exposes hidden problems”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.
Give each module one game-hacking job
Section titled “Give each module one game-hacking job”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 addressesobserve.rs turn verified Win32 reads into local snapshotsdecide.rs compute alarms and feature decisions from a snapshotapply.rs own guarded writes, patches, hooks, and restorationui.rs turn user events into commands and display acknowledgementsmain.rs connect those pieces and control their lifetimeEach 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:
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.
Give every feature a lifecycle
Section titled “Give every feature a lifecycle”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.
Model configuration with enums
Section titled “Model configuration with enums”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.
Route input as commands
Section titled “Route input as commands”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.
Contain feature failures
Section titled “Contain feature failures”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.
Shut down in reverse order
Section titled “Shut down in reverse order”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.
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.
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.
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.
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.
Data Restore bytes, unload the DLL
Step 1: Owner (main.rs) to Game process
The owner skipped stopping and joining the worker.
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.
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.
Keep version facts outside feature logic
Section titled “Keep version facts outside feature logic”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.
Reuse without surrendering judgment
Section titled “Reuse without surrendering judgment”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:
- Can the standard library or an existing project abstraction do it clearly?
- Is there a maintained crate whose API, license, and safety model fit?
- Is there a small licensed example you can adapt and test?
- 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.
Know when the combined tool is ready
Section titled “Know when the combined tool is ready”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.
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?
Cleanup releases the failing feature’s resources; its disabled slot prevents another update from using them. One owner makes this state change and allows the other enabled features to continue.
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.
Plain Markdown, saved in this browser.
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.