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

Menus as State and Command Interfaces

See how a settings menu expresses feature state, sends commands to a worker, and shuts down without leaving stale changes.

Lesson 8.6
Read or listenPrint chapter or book
About 38 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.6Key ideas, reference and questions

A one-page summary of “Menus as State and Command Interfaces” you can keep beside your notes. Plain Markdown.

Lesson 8.6 — Menus as State and Command Interfaces: cheatsheet

  • Remember: The menu sends owned requests; the worker owns process operations and reports whether they actually succeeded.
  • Also useful: Immediate mode redraws a description from saved settings; retained mode keeps the interface objects themselves. Both still need somewhere to keep the chosen values.

Key terms

  • MenuApp: The MenuApp stores the setting between frames; the widget does not.
  • state synchronization: This is state synchronization in miniature.
  • settings: settings is the proposed state.
  • last_applied: last_applied is the last state the UI knows it submitted.

Review

  • Q: Why should an egui menu send an owned Settings command instead of writing game memory directly during every UI frame? A: Repainting stays side-effect free, and one worker owns validated handles and cleanup
  • Q: Which four questions should a game-tool menu answer clearly? A: What features exist, each one's state, what reached the worker, and how shutdown restores normal
Download cheatsheet (.md)

A menu is a control surface, not the hack itself

Section titled “A menu is a control surface, not the hack itself”

A useful game-tool menu answers four questions clearly:

  1. What features exist?
  2. What state is each feature in?
  3. Which changes have actually reached the worker?
  4. How does everything return to normal during shutdown?

The menu should not own a process handle, execute pointer chains while painting a checkbox, or patch memory sixty times per second. Its job is to edit ordinary settings and send deliberate commands to a worker. That separation makes the interface responsive and the low-level code reviewable. 🎛️

egui widgets
owned Settings
channel
tool worker
handles, reads,
writes, cleanup

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

The menu window
  • The "Show names" checkbox. A checkbox only edits an ordinary setting (show_names). The menu’s job is to edit settings, so this belongs here.
  • The field-of-view slider. A slider borrows one setting for a moment and changes it when you drag. That is ordinary UI work, so it stays in the menu.
  • Remembering the last settings sent (last_applied). The menu keeps what it last submitted so it can say "Nothing changed" instead of resending identical settings.
  • Sending Command::Shutdown when the window closes. on_exit in the menu only sends the command. The worker does the actual cleanup, and the main thread waits for it.
The worker
  • The process handle. The worker is the only place that should own Windows handles, so the low-level code can be reviewed in one place.
  • Reading and writing game memory. The menu must not patch memory sixty times per second. Low-level work waits for a deliberate command and runs in the worker.
  • Walking a pointer chain. The lesson says the menu should not execute pointer chains while painting a checkbox. That work belongs to the worker.
  • Restoring changed bytes at shutdown. On Shutdown the worker restores what it changed, unhooks only its own hooks and closes its handles. The window vanishing proves nothing; joining the worker does.

This tutorial builds a separate desktop window. That is the best first menu: it is easy to debug, does not run under loader lock, and can stay open beside Wesnoth, AssaultCube, or another local course target. Integrating the same widgets into an injected renderer is a later graphics-backend problem.

menu_lab.rs

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

Show the codeHide the code
use std::{
    sync::mpsc::{self, Receiver, Sender},
    thread,
};

use eframe::egui;

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum OverlayMode {
    #[default]
    Off,
    TeamOnly,
    AllActors,
}

#[derive(Clone, Debug, PartialEq)]
struct Settings {
    overlay: OverlayMode,
    show_names: bool,
    show_distance: bool,
    field_of_view: f32,
    observation_only: bool,
}

impl Default for Settings {
    fn default() -> Self {
        Self {
            overlay: OverlayMode::Off,
            show_names: true,
            show_distance: true,
            field_of_view: 75.0,
            observation_only: true,
        }
    }
}

#[derive(Debug)]
enum Command {
    Apply(Settings),
    RestoreDefaults,
    Shutdown,
}

struct MenuApp {
    settings: Settings,
    last_applied: Settings,
    commands: Sender<Command>,
    status: String,
}

impl MenuApp {
    fn new(commands: Sender<Command>) -> Self {
        Self {
            settings: Settings::default(),
            last_applied: Settings::default(),
            commands,
            status: "Ready — no target changes have been requested.".into(),
        }
    }

    fn apply_if_changed(&mut self) {
        if self.settings == self.last_applied {
            self.status = "Nothing changed.".into();
            return;
        }

        // 📬 The UI sends owned data. The worker never borrows widget state.
        match self.commands.send(Command::Apply(self.settings.clone())) {
            Ok(()) => {
                self.last_applied = self.settings.clone();
                self.status = "Settings sent to the tool worker.".into();
            }
            Err(_) => self.status = "Worker stopped; no settings were applied.".into(),
        }
    }
}

impl eframe::App for MenuApp {
    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
        egui::CentralPanel::default().show(ui, |ui| {
            ui.heading("Game Hacking Academy — lab menu");
            ui.label("A separate settings window for the offline target.");
            ui.separator();

            egui::ComboBox::from_label("Overlay mode")
                .selected_text(format!("{:?}", self.settings.overlay))
                .show_ui(ui, |ui| {
                    ui.selectable_value(&mut self.settings.overlay, OverlayMode::Off, "Off");
                    ui.selectable_value(
                        &mut self.settings.overlay,
                        OverlayMode::TeamOnly,
                        "Teammates only",
                    );
                    ui.selectable_value(
                        &mut self.settings.overlay,
                        OverlayMode::AllActors,
                        "All local actors",
                    );
                });

            ui.checkbox(&mut self.settings.show_names, "Show names");
            ui.checkbox(&mut self.settings.show_distance, "Show distance");
            ui.add(
                egui::Slider::new(&mut self.settings.field_of_view, 30.0..=140.0)
                    .text("Field of view"),
            );

            // 🔒 Observation-only is the safe default and stays visually clear.
            ui.checkbox(
                &mut self.settings.observation_only,
                "Observation only (no writes or synthetic input)",
            );

            ui.add_space(8.0);
            ui.horizontal(|ui| {
                if ui.button("Apply changes").clicked() {
                    self.apply_if_changed();
                }

                if ui.button("Restore defaults").clicked() {
                    self.settings = Settings::default();
                    let _ = self.commands.send(Command::RestoreDefaults);
                    self.last_applied = self.settings.clone();
                    self.status = "Defaults restored.".into();
                }
            });

            ui.separator();
            ui.label(&self.status);
            ui.small("Tip: the worker owns process handles; this window owns only settings.");
        });
    }

    fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
        // 🧹 Give the worker one explicit cleanup path before the sender drops.
        let _ = self.commands.send(Command::Shutdown);
    }
}

fn lab_worker(commands: Receiver<Command>) {
    while let Ok(command) = commands.recv() {
        match command {
            Command::Apply(settings) => {
                // 🧪 Replace this print with the book's verified local-lab
                // adapter. Keep every raw handle and memory write in that worker.
                println!("apply: {settings:?}");
            }
            Command::RestoreDefaults => println!("restore defaults"),
            Command::Shutdown => {
                // 🧹 Restore patches and release held input here, then exit.
                println!("worker shutdown");
                break;
            }
        }
    }
}

fn main() -> eframe::Result {
    let (command_tx, command_rx) = mpsc::channel();
    let worker = thread::spawn(move || lab_worker(command_rx));

    let options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default()
            .with_inner_size([620.0, 470.0])
            .with_min_inner_size([440.0, 360.0]),
        centered: true,
        ..Default::default()
    };

    let result = eframe::run_native(
        "GHA tool menu",
        options,
        Box::new(move |_creation| Ok(Box::new(MenuApp::new(command_tx)))),
    );

    // ✅ `on_exit` stops the worker; joining proves cleanup completed.
    let _ = worker.join();
    result
}

The buildable course version lives in menu_lab.rs. It uses eframe 0.36, whose egui UI is well suited to tools that redraw interactive controls every frame:

[dependencies]
eframe = { version = "0.36", default-features = false, features = [
"default_fonts",
"glow",
] }

From the windows-labs directory, run:

Terminal window
cargo run --bin menu_lab

The first build downloads and compiles the windowing and OpenGL support, so it will take longer than later runs.

Avoid one unrelated global boolean per feature. A settings value is easier to copy, compare, validate, save, and send:

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum OverlayMode {
#[default]
Off,
TeamOnly,
AllActors,
}
#[derive(Clone, Debug, PartialEq)]
struct Settings {
overlay: OverlayMode,
show_names: bool,
show_distance: bool,
field_of_view: f32,
observation_only: bool,
}

The enum is important. Three booleans named overlay_off, team_overlay, and all_overlay could all become true at once. One OverlayMode value can hold exactly one variant. ✅

observation_only defaults to true. A safe default is a design feature, not a warning paragraph the user has to remember.

In a retained UI toolkit, you create a checkbox object and the framework keeps it. In egui’s immediate mode, your ui function describes the interface again each frame:

ui.checkbox(&mut self.settings.show_names, "Show names");
ui.add(
egui::Slider::new(&mut self.settings.field_of_view, 30.0..=140.0)
.text("Field of view"),
);

The checkbox borrows show_names for this call. It reads the current value, draws the correct box, and changes the value if the user clicks. The MenuApp stores the setting between frames; the widget does not.

This is why low-level work must not happen unconditionally in ui:

This intentionally bad menu variant draws a slider and immediately writes its current value to the owned lab process. It assumes a Process in self.process and a verified FOV_ADDRESS; the real course menu below only sends commands. Immediate-mode UI redraws repeatedly, so repainting the bad variant repeats that side effect.

Before: write memory whenever the menu redraws
impl MenuApp {
fn draw_settings(&mut self, ui: &mut egui::Ui) -> anyhow::Result<()> {
ui.add(egui::Slider::new(&mut self.settings.field_of_view, 30.0..=140.0));
self.process.write_exact(
FOV_ADDRESS, &self.settings.field_of_view.to_le_bytes(),
)?; // Every frame.
Ok(())
}
}

Make the user event explicit: clicking Apply sends one command to the worker. The worker still validates the request and reports whether it was applied; queuing a command is not proof that a write succeeded.

Improved code

impl MenuApp {
fn draw_settings(&mut self, ui: &mut egui::Ui) -> anyhow::Result<()> {
ui.add(egui::Slider::new(&mut self.settings.field_of_view, 30.0..=140.0));
if ui.button("Apply changes").clicked() {
self.commands.send(Command::Apply(self.settings.clone()))?; // One request.
}
Ok(())
}
}

Show the diff

impl MenuApp {
fn draw_settings(&mut self, ui: &mut egui::Ui) -> anyhow::Result<()> {
ui.add(egui::Slider::new(&mut self.settings.field_of_view, 30.0..=140.0));
self.process.write_exact(
FOV_ADDRESS, &self.settings.field_of_view.to_le_bytes(),
)?; // Every frame.
if ui.button("Apply changes").clicked() {
self.commands.send(Command::Apply(self.settings.clone()))?; // One request.
}
Ok(())
}
}

Takeaway: a redraw displays state; an explicit command requests a state change.

The first version repeats a side effect merely because the menu repainted. The second version names the user action that triggers one update.

Design the event before drawing the widget

Section titled “Design the event before drawing the widget”

An event-driven tool waits for something meaningful to happen, then runs the small handler that owns that event. Describe each interaction before choosing colors, icons, or panel layouts:

User event UI command Worker result Visible feedback
clicks Apply changes Apply(settings) accepted or rejected exact status and reason
clicks Restore defaults RestoreDefaults features disabled and state restored “Restored” only after acknowledgement
presses Escape close the menu no game-state change menu disappears
closes the app Shutdown keys released, patches restored, handles closed window exits after worker joins

This table prevents a common UI logic bug: the button changes its label or color, but no one can say whether the worker completed the requested action. The worker owns the game-hacking side effect; the menu owns the request and the truthful report of its result.

Give the worker commands, not widget references

Section titled “Give the worker commands, not widget references”

The menu and worker may run on different threads. Send owned data through a channel:

#[derive(Debug)]
enum Command {
Apply(Settings),
RestoreDefaults,
Shutdown,
}
let (command_tx, command_rx) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || lab_worker(command_rx));

Apply(Settings) owns a snapshot. The UI can keep changing its copy without racing with the worker. The worker is the only place that should own Windows handles and call the verified adapters from earlier lessons.

The course lab intentionally prints settings instead of modifying a game:

fn lab_worker(commands: Receiver<Command>) {
while let Ok(command) = commands.recv() {
match command {
Command::Apply(settings) => println!("apply: {settings:?}"),
Command::RestoreDefaults => println!("restore defaults"),
Command::Shutdown => break,
}
}
}

Replace only that adapter with a version-checked game bridge. The UI architecture stays the same whether the worker reads a snapshot, controls an overlay, or applies the guarded Wesnoth gold change from Chapter 3.

Keep both the currently edited value and the last value sent:

yes
no
user edits a widget
self.settings changes
settings == last_applied?
status: “Nothing changed.”
send Command::Apply(settings.clone())
last_applied = settings.clone()
status: “Settings sent to the tool worker.”

The method below implements the decision in that diagram. It sends a command only when settings differ from the last sent values; a successful send updates the remembered copy and the message the user sees. The worker still has to report whether it actually applied the command.

fn apply_if_changed(&mut self) {
if self.settings == self.last_applied {
self.status = "Nothing changed.".into();
return;
}
if self.commands.send(Command::Apply(self.settings.clone())).is_ok() {
self.last_applied = self.settings.clone();
self.status = "Settings sent to the tool worker.".into();
}
}

This is state synchronization in miniature. settings is the proposed state. last_applied is the last state the UI knows it submitted. In a more advanced tool, the worker can answer with an acknowledgement so the menu shows “applied,” “rejected,” or “target closed” instead of guessing.

Add a menu hotkey without accidental repeats

Section titled “Add a menu hotkey without accidental repeats”

Use the rising-edge helper from Lesson 6.9 for VK_INSERT. Polling GetAsyncKeyState(VK_INSERT) and toggling on every frame while it is negative will make the menu flicker open and closed. Toggle only when the key changes from up to down:

if insert_edge.pressed_now(VK_INSERT) {
menu_open = !menu_open;
}

When the menu is open, decide who owns input:

  • a separate menu window receives input only while the user focuses it;
  • an injected overlay should ask egui whether it wants pointer or keyboard input before forwarding those events to the game;
  • closing the menu must release any synthetic key or mouse button the worker still owns;
  • losing target focus should pause active input features.

Do not “fix” focus by continuously forcing the game window to the foreground. Let the user choose which application receives input.

Good menus are boring in the best way:

  • use verbs such as Apply changes and Restore defaults;
  • group read-only, overlay, and input features separately;
  • show units on numbers (degrees, pixels, milliseconds);
  • disable controls that cannot currently work and explain why;
  • display the target build and connection state near the top;
  • keep dangerous actions behind an explicit confirmation;
  • never use color as the only sign of success or failure.
  • keep Stop, Restore defaults, and Escape easy to reach;
  • preserve keyboard navigation and readable contrast at every theme setting;
  • remember cosmetic preferences without silently remembering an unsafe active feature.

The full example sets a minimum window size. Otherwise, a narrow window can wrap a label such as Restore defaults into a column of one-letter lines while the button is still technically present. Desktop tool menus need enough width for their actual text and controls.

MenuApp::on_exit sends Command::Shutdown. The worker should then:

  1. stop accepting new feature commands;
  2. release held keys and mouse buttons;
  3. restore bytes or graphics state it changed;
  4. unhook only hooks it installed;
  5. close its owned handles;
  6. report completion and exit.
MenuApp::on_exit
sends Command::Shutdown
stop accepting new
feature commands
release held keys
and mouse buttons
restore bytes or
graphics state changed
unhook only hooks
this tool installed
close owned handles
report completion;
main thread joins worker

The main thread joins the worker before returning. A window disappearing is not proof that cleanup finished; joining turns cleanup into something the program actually waits for.

Once this separate menu behaves correctly, reuse its Settings and Command types in an overlay. The overlay-specific layer must additionally:

  • track the game client rectangle with GetClientRect and ClientToScreen;
  • respond to DPI and resize changes;
  • switch between click-through mode when closed and interactive mode when open;
  • save and restore the game’s graphics state if drawing inside its renderer;
  • keep the target’s update thread free of file I/O, sleeping, and channel waits.

Build the control model first. Each feature still needs an owner, a defined set of commands, and a way to restore any bytes it changes when the menu moves into the game. The tool-architecture lesson combines those responsibilities with snapshots, feature lifecycles, version checks, and failure handling. Chapter 7 then follows the camera and drawing state that an in-game display would need.

Knowledge check

Keep painting separate from side effects

Why should an egui menu send an owned Settings command instead of writing game memory directly during every UI frame?

Finished Lesson 8.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 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.