Sign in Sign up
kretrod/lodestone Public
Branches
master
2086 lines (1983 loc) · 78.2 KB Raw
//! The menu, drawn with Dear ImGui inside the game's own OpenGL context.
//!
//! ImGui is a mature immediate-mode UI toolkit that ships as vendored C++ — no
//! install, Cargo builds it — and renders through the game's GL context. It
//! gives a polished, themeable widget set (checkboxes, sliders, combos, child
//! panels with real scrollbars) instead of hand-drawn rectangles.
//!
//! The game is mid-frame when our hook runs, so its shader program, sampler
//! objects, blend mode and pixel-transfer settings are all set up for whatever
//! it was drawing. We save that state, neutralise what ImGui's renderer
//! assumes is default, draw, and restore it, or the game's next frame renders
//! wrong.

use std::time::Instant;

use imgui::{
    Condition, Context, DrawListMut, FontConfig, FontSource, Key, MouseButton, StyleColor,
    TextureId, Ui,
};
use imgui_glow_renderer::glow::{self, HasContext};
use imgui_glow_renderer::AutoRenderer;

use crate::state::{
    AimMode, AuraTarget, BindTarget, BoxStyle, FlyMode, ModuleId, Shared, SpeedMode, TotemMode,
    UiInput,
};

/// Poppins — a geometric sans (SIL OFL) that gives the menu a modern, premium
/// weight, close to the paid-cheat reference. Embedded so the client stays one
/// file; drop a `ui-font.ttf` beside the DLL to override it without a rebuild.
const FONT: &[u8] = include_bytes!("../assets/Poppins-Regular.ttf");

// The Enigma (CS2) palette: near-black panels, a single accent (customizable at
// runtime — see `accent()`), crisp white ticks, muted grey for the inactive.
const ACCENT_DEFAULT: [f32; 4] = [0.718, 0.804, 0.831, 1.0]; // pale steel #B7CDD4
const TEXT: [f32; 4] = [0.91, 0.91, 0.93, 1.0];
const DIM: [f32; 4] = [0.46, 0.46, 0.52, 1.0];
const WARN: [f32; 4] = [0.94, 0.67, 0.35, 1.0];
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
const BORDER: [f32; 4] = [0.17, 0.17, 0.20, 1.0];
const CHECK_OFF: [f32; 4] = [0.34, 0.34, 0.40, 1.0]; // empty checkbox outline
const NAV_HOVER_BG: [f32; 4] = [1.0, 1.0, 1.0, 0.05];
const ROW_HOVER: [f32; 4] = [1.0, 1.0, 1.0, 0.035];

thread_local! {
    /// The live accent, refreshed from config at the top of every frame so every
    /// hand-drawn widget follows the user's chosen colour.
    static ACCENT_NOW: std::cell::Cell<[f32; 4]> = std::cell::Cell::new(ACCENT_DEFAULT);
}

/// The current accent colour.
fn accent() -> [f32; 4] {
    ACCENT_NOW.with(|a| a.get())
}

/// The current accent colour at a given alpha.
fn accent_a(alpha: f32) -> [f32; 4] {
    let a = accent();
    [a[0], a[1], a[2], alpha]
}

thread_local! {
    /// The live UI scale, refreshed at the top of every frame. ImGui's own
    /// style is scaled with `scale_all_sizes`; this is what scales the widgets
    /// we draw by hand, so the two stay in step.
    static SCALE_NOW: std::cell::Cell<f32> = const { std::cell::Cell::new(1.0) };
}

/// The current UI scale.
fn scale() -> f32 {
    SCALE_NOW.with(|s| s.get())
}

/// A hand-drawn dimension, in scaled pixels. Every literal size in the menu
/// goes through here; the in-world ESP deliberately does not, because its
/// coordinates come from the world projection and must stay 1:1 with the game.
fn px(v: f32) -> f32 {
    v * scale()
}

fn rgb(r: u8, g: u8, b: u8) -> [f32; 4] {
    [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
}
fn rgba(r: u8, g: u8, b: u8, a: u8) -> [f32; 4] {
    [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a as f32 / 255.0]
}

/// Pack an RGBA float colour into the u32 ImGui draw lists want (IM_COL32:
/// R | G<<8 | B<<16 | A<<24).
fn col(c: [f32; 4]) -> u32 {
    let q = |x: f32| (x.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
    q(c[0]) | (q(c[1]) << 8) | (q(c[2]) << 16) | (q(c[3]) << 24)
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum Tab {
    Combat,
    Movement,
    World,
    Esp,
    Visuals,
    Misc,
    Settings,
}

impl Tab {
    const ALL: [Tab; 7] = [
        Tab::Combat,
        Tab::Movement,
        Tab::World,
        Tab::Esp,
        Tab::Visuals,
        Tab::Misc,
        Tab::Settings,
    ];
    fn label(self) -> &'static str {
        match self {
            Tab::Combat => "Combat",
            Tab::Movement => "Movement",
            Tab::World => "World",
            Tab::Esp => "ESP",
            Tab::Visuals => "Visuals",
            Tab::Misc => "Misc",
            Tab::Settings => "Settings",
        }
    }
}

pub struct Overlay {
    imgui: Context,
    renderer: AutoRenderer,
    /// The themed style at scale 1, kept pristine so the UI-size slider can
    /// re-scale from it instead of compounding.
    base_style: imgui::Style,
    applied_scale: f32,
    tab: Tab,
    frame_times: [f32; 60],
    frame_i: usize,
    last_frame: Instant,
    logged_state: bool,
}

impl Overlay {
    pub fn new() -> Result<Self, String> {
        // SAFETY: the game's GL context is current on this thread — we are
        // inside its swap-buffers call.
        let gl = unsafe {
            glow::Context::from_loader_function(|name| gl_proc(name) as *const std::ffi::c_void)
        };
        // SAFETY: as above.
        unsafe {
            crate::log(&format!(
                "gl version {:?} renderer {:?}",
                gl.get_parameter_string(glow::VERSION),
                gl.get_parameter_string(glow::RENDERER)
            ));
        }

        let mut imgui = Context::create();
        imgui.set_ini_filename(None);
        imgui.io_mut().display_size = [1280.0, 720.0];

        // A file beside the DLL wins, so the face can be swapped without a build.
        let external = crate::client_dir().join("ui-font.ttf");
        let bytes = std::fs::read(&external).unwrap_or_else(|_| FONT.to_vec());
        imgui.fonts().add_font(&[FontSource::TtfData {
            data: &bytes,
            // 16px with 3x horizontal / 2x vertical oversampling: crisp, evenly
            // weighted glyphs. A small rasteriser boost keeps thin strokes solid
            // so dim grey labels stay legible at menu size.
            size_pixels: 16.0,
            config: Some(FontConfig {
                oversample_h: 3,
                oversample_v: 2,
                pixel_snap_h: false,
                rasterizer_multiply: 1.15,
                ..FontConfig::default()
            }),
        }]);
        theme(imgui.style_mut());
        let base_style = *imgui.style_mut();

        // AutoRenderer::new uploads the font atlas immediately. We are inside
        // the game's swap-buffers call, so its pixel-transfer state is still
        // set for whatever it was drawing (UNPACK_ROW_LENGTH != 0, a sampler on
        // unit 0). Uploading the atlas under that state bakes in a garbled font
        // — the "cursed text". Neutralise around construction, then restore.
        // SAFETY: render thread, game GL context current.
        let renderer = unsafe {
            let saved = GlState::save(&gl);
            saved.neutralise(&gl);
            let r = AutoRenderer::new(gl, &mut imgui).map_err(|e| format!("imgui renderer: {e}"));
            if let Ok(ref r) = r {
                saved.restore(r.gl_context());
            }
            r?
        };
        Ok(Self {
            imgui,
            renderer,
            base_style,
            applied_scale: 1.0,
            tab: Tab::Combat,
            frame_times: [0.0; 60],
            frame_i: 0,
            last_frame: Instant::now(),
            logged_state: false,
        })
    }

    pub fn fps(&self) -> f32 {
        let sum: f32 = self.frame_times.iter().sum();
        let n = self.frame_times.iter().filter(|t| **t > 0.0).count();
        if n == 0 || sum <= 0.0 {
            0.0
        } else {
            n as f32 / sum
        }
    }

    pub fn draw(&mut self, width: i32, height: i32, shared: &mut Shared) {
        let dt = self.last_frame.elapsed().as_secs_f32().max(1.0 / 1000.0);
        self.last_frame = Instant::now();
        self.frame_times[self.frame_i] = dt;
        self.frame_i = (self.frame_i + 1) % self.frame_times.len();

        let open = shared.menu_open;
        // Feed input and per-frame state into ImGui's IO.
        {
            let io = self.imgui.io_mut();
            io.display_size = [width.max(1) as f32, height.max(1) as f32];
            io.delta_time = dt;
            io.font_global_scale = shared.cfg.ui_scale.clamp(0.6, 2.5);
            for ev in std::mem::take(&mut shared.events) {
                match ev {
                    UiInput::MouseMove(x, y) => io.add_mouse_pos_event([x, y]),
                    UiInput::MouseButton(b, down) => {
                        let btn = match b {
                            0 => MouseButton::Left,
                            1 => MouseButton::Right,
                            _ => MouseButton::Middle,
                        };
                        io.add_mouse_button_event(btn, down);
                    }
                    UiInput::Wheel(d) => io.add_mouse_wheel_event([0.0, d]),
                    UiInput::Key(vk, down) => {
                        if let Some(k) = vk_to_key(vk) {
                            io.add_key_event(k, down);
                        }
                    }
                    UiInput::Char(c) => io.add_input_character(c),
                }
            }
        }

        // UI size: a real scale, not just a font size. The whole style —
        // padding, rounding, spacing, every widget metric — is re-scaled from
        // the pristine copy, and `px()` scales what we draw by hand. Only done
        // when the value actually changes (the slider commits on release),
        // because scaling an already-scaled style would compound.
        let want_scale = shared.cfg.ui_scale.clamp(0.6, 2.5);
        if (self.applied_scale - want_scale).abs() > f32::EPSILON {
            self.applied_scale = want_scale;
            let base = self.base_style;
            let style = self.imgui.style_mut();
            *style = base;
            style.scale_all_sizes(want_scale);
        }
        SCALE_NOW.with(|s| s.set(want_scale));

        // Publish the accent for the hand-drawn widgets, and re-tint the
        // accent-dependent built-in ones (sliders, buttons, dropdown rows).
        ACCENT_NOW.with(|a| a.set(shared.cfg.accent));
        {
            let ac = shared.cfg.accent;
            let c = &mut self.imgui.style_mut().colors;
            c[StyleColor::SliderGrab as usize] = ac;
            c[StyleColor::SliderGrabActive as usize] = ac;
            c[StyleColor::ButtonActive as usize] = ac;
            c[StyleColor::HeaderActive as usize] = ac;
            c[StyleColor::Header as usize] = [ac[0], ac[1], ac[2], 0.10];
        }

        let mut tab = self.tab;
        let ui = self.imgui.new_frame();

        world_overlay(ui, shared);
        hud(ui, shared, open);
        if open {
            build_menu(ui, shared, &mut tab);
        }
        self.tab = tab;

        let skin_tex = shared.game.skin_texture;
        let draw_data = self.imgui.render();
        let gl = self.renderer.gl_context().clone();
        // SAFETY: render thread, game GL context current. We save the state the
        // game left, neutralise it, draw, and put it all back.
        unsafe {
            let saved = GlState::save(&gl);
            if !self.logged_state {
                self.logged_state = true;
                saved.log_once();
            }
            saved.neutralise(&gl);
            // A skin is pixel art. Sampled with the default linear filter it
            // comes out a blurred smear, so ask for nearest-neighbour — one
            // texel, one pixel. This is texture state rather than sampler
            // state, and the game binds its own sampler object when it draws
            // the skin itself, so this only changes how *we* read it.
            if skin_tex != 0 {
                if let Some(t) = texture(skin_tex as i32) {
                    gl.bind_texture(glow::TEXTURE_2D, Some(t));
                    let nearest = glow::NEAREST as i32;
                    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, nearest);
                    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, nearest);
                }
            }
            let _ = self.renderer.render(draw_data);
            saved.restore(&gl);
        }
    }
}

// ---------------------------------------------------------------------------
// the menu
// ---------------------------------------------------------------------------

fn build_menu(ui: &Ui, shared: &mut Shared, tab: &mut Tab) {
    // One window, three panes — Enigma's layout without the panels drifting
    // apart: a navigation rail that selects the category, a content pane for it,
    // and a third pane showing a live ESP preview on the ESP tab and a status
    // readout everywhere else. The whole menu drags as a single unit.
    ui.window("##lodestone")
        .title_bar(false)
        .size([px(968.0), px(484.0)], Condition::FirstUseEver)
        .position([60.0, 56.0], Condition::FirstUseEver)
        .size_constraints([px(640.0), px(320.0)], [3200.0, 2400.0])
        .build(|| {
            let rail = px(196.0);
            let side = px(280.0);
            sidebar(ui, shared, tab, rail);
            ui.same_line();
            // The content pane takes whatever the other two do not.
            let middle = (ui.content_region_avail()[0] - side - px(16.0)).max(px(220.0));
            content(ui, shared, *tab, middle);
            ui.same_line();
            if *tab == Tab::Esp {
                esp_preview(ui, shared, 0.0);
            } else {
                status_panel(ui, shared, 0.0);
            }
        });
}

/// The left navigation rail: logo, category list, and a user footer.
fn sidebar(ui: &Ui, shared: &mut Shared, tab: &mut Tab, width: f32) {
    ui.child_window("##nav")
        .size([width, 0.0])
        .build(|| {
            // Logo strip: name centred over a pink underline.
            {
                let p = ui.cursor_screen_pos();
                let w = ui.content_region_avail()[0];
                let dl = ui.get_window_draw_list();
                let title = "LODESTONE";
                let tw = ui.calc_text_size(title)[0];
                dl.add_text([p[0] + (w - tw) / 2.0, p[1] + px(12.0)], col(TEXT), title);
                let y = p[1] + px(40.0);
                dl.add_line([p[0], y], [p[0] + w, y], col(accent()))
                    .thickness(px(1.5))
                    .build();
            }
            ui.dummy([0.0, px(54.0)]);

            for t in Tab::ALL {
                if nav_item(ui, t.label(), *tab == t) {
                    *tab = t;
                }
            }

            // Footer pinned to the bottom: avatar dot, name, world state.
            let rest = ui.content_region_avail()[1];
            if rest > px(52.0) {
                ui.dummy([0.0, rest - px(46.0)]);
            }
            let p = ui.cursor_screen_pos();
            let w = ui.content_region_avail()[0];
            let dl = ui.get_window_draw_list();
            dl.add_line([p[0], p[1]], [p[0] + w, p[1]], col(BORDER)).build();
            let g = &shared.game;
            let (stat, sc) = if !g.in_world {
                ("idle", DIM)
            } else if g.single_player {
                ("single player", accent())
            } else {
                ("server", WARN)
            };
            dl.add_circle([p[0] + px(12.0), p[1] + px(22.0)], px(10.0), col(accent()))
                .filled(true)
                .build();
            dl.add_text([p[0] + px(30.0), p[1] + px(13.0)], col(TEXT), "player");
            dl.add_text([p[0] + px(30.0), p[1] + px(29.0)], col(sc), stat);
        });
}

/// The centre content panel: breadcrumb header plus the selected category.
fn content(ui: &Ui, shared: &mut Shared, tab: Tab, width: f32) {
    ui.child_window("##content")
        .size([width, 0.0])
        .build(|| {
            {
                let p = ui.cursor_screen_pos();
                let w = ui.content_region_avail()[0];
                let dl = ui.get_window_draw_list();
                dl.add_circle([p[0] + px(5.0), p[1] + px(8.0)], px(4.0), col(accent()))
                    .filled(true)
                    .build();
                dl.add_text([p[0] + px(18.0), p[1] + px(1.0)], col(accent()), tab.label());
                let y = p[1] + px(26.0);
                dl.add_line([p[0], y], [p[0] + w, y], col(accent()))
                    .thickness(1.0)
                    .build();
            }
            ui.dummy([0.0, px(34.0)]);
            match tab {
                Tab::Combat => combat(ui, shared),
                Tab::Movement => movement(ui, shared),
                Tab::World => world(ui, shared),
                Tab::Esp => esp(ui, shared),
                Tab::Visuals => visuals(ui, shared),
                Tab::Misc => misc(ui, shared),
                Tab::Settings => settings(ui, shared),
            }
        });
}

/// The right status panel: live world state, mirroring Enigma's preview column.
fn status_panel(ui: &Ui, shared: &Shared, width: f32) {
    ui.child_window("##status")
        .size([width, 0.0])
        .build(|| {
            {
                let p = ui.cursor_screen_pos();
                let w = ui.content_region_avail()[0];
                let dl = ui.get_window_draw_list();
                dl.add_circle([p[0] + px(5.0), p[1] + px(8.0)], px(4.0), col(accent()))
                    .filled(true)
                    .build();
                dl.add_text([p[0] + px(18.0), p[1] + px(1.0)], col(TEXT), "Status");
                let y = p[1] + px(26.0);
                dl.add_line([p[0], y], [p[0] + w, y], col(accent()))
                    .thickness(1.0)
                    .build();
            }
            ui.dummy([0.0, px(34.0)]);

            let g = &shared.game;
            let world = if !g.in_world {
                ("no world", DIM)
            } else if g.single_player {
                ("singleplayer", accent())
            } else {
                ("server", WARN)
            };
            stat_row(ui, "state", world.0, world.1);
            stat_row(ui, "fps", &format!("{:.0}", g.fps), TEXT);
            stat_row(ui, "health", &format!("{:.0}", g.health), TEXT);
            stat_row(
                ui,
                "position",
                &format!("{:.0} {:.0} {:.0}", g.pos.0, g.pos.1, g.pos.2),
                TEXT,
            );
            stat_row(ui, "entities", &format!("{}", g.targets.len()), TEXT);
            stat_row(ui, "chunks", &format!("{}", g.loaded_chunks), TEXT);
            stat_row(ui, "active", &format!("{}", active_modules(&shared.cfg).len()), accent());
            if let Some((_, d)) = g.ghost {
                stat_row(ui, "server you", &format!("{d:.1}m"), if d > 1.0 { WARN } else { accent() });
            }
            if !g.server_brand.is_empty() {
                stat_row(ui, "brand", &g.server_brand.clone(), DIM);
            }
        });
}

// ---- widgets ---------------------------------------------------------------

fn colored(ui: &Ui, text: &str, c: [f32; 4]) {
    let _t = ui.push_style_color(StyleColor::Text, c);
    ui.text(text);
}

/// A category button in the navigation rail. Active shows a pink label and left
/// accent bar; hover shows a faint fill.
fn nav_item(ui: &Ui, label: &str, active: bool) -> bool {
    let start = ui.cursor_screen_pos();
    let w = ui.content_region_avail()[0];
    let h = px(36.0);
    let pad = px(6.0);
    let clicked = ui.invisible_button(format!("##nav_{label}"), [w, h]);
    let hovered = ui.is_item_hovered();
    let dl = ui.get_window_draw_list();
    if active {
        dl.add_rect([start[0] - pad, start[1]], [start[0] + w + pad, start[1] + h], col(accent_a(0.10)))
            .filled(true)
            .rounding(px(6.0))
            .build();
        dl.add_rect(
            [start[0] - pad, start[1] + px(7.0)],
            [start[0] - px(3.0), start[1] + h - px(7.0)],
            col(accent()),
        )
        .filled(true)
        .rounding(px(2.0))
        .build();
    } else if hovered {
        dl.add_rect([start[0] - pad, start[1]], [start[0] + w + pad, start[1] + h], col(NAV_HOVER_BG))
            .filled(true)
            .rounding(px(6.0))
            .build();
    }
    let c = if active {
        accent()
    } else if hovered {
        TEXT
    } else {
        DIM
    };
    dl.add_circle([start[0] + px(8.0), start[1] + h / 2.0], px(3.5), col(c)).filled(true).build();
    dl.add_text([start[0] + px(22.0), start[1] + (h - px(16.0)) / 2.0], col(c), label);
    clicked
}

/// A read-only "label ........ value" row for the status panel.
fn stat_row(ui: &Ui, label: &str, value: &str, vc: [f32; 4]) {
    let start = ui.cursor_screen_pos();
    let w = ui.content_region_avail()[0];
    ui.dummy([w, px(22.0)]);
    let dl = ui.get_window_draw_list();
    dl.add_text([start[0], start[1] + px(3.0)], col(DIM), label);
    let vw = ui.calc_text_size(value)[0];
    dl.add_text([start[0] + w - vw, start[1] + px(3.0)], col(vc), value);
}

/// The Enigma checkbox: a rounded square that fills with the accent and shows a
/// white tick when on, an outlined empty box when off. `width` is the clickable
/// row width. Returns whether it toggled this frame.
fn enigma_check(ui: &Ui, label: &str, on: &mut bool, width: f32) -> bool {
    let row_h = px(26.0);
    let box_sz = px(17.0);
    let pad = px(6.0);
    let start = ui.cursor_screen_pos();
    let clicked = ui.invisible_button(format!("##ec_{label}"), [width.max(box_sz + px(30.0)), row_h]);
    if clicked {
        *on = !*on;
    }
    let hovered = ui.is_item_hovered();
    let dl = ui.get_window_draw_list();
    if hovered {
        dl.add_rect([start[0] - pad, start[1]], [start[0] + width + pad, start[1] + row_h], col(ROW_HOVER))
            .filled(true)
            .rounding(px(4.0))
            .build();
    }
    let bx = start[0];
    let by = start[1] + (row_h - box_sz) / 2.0;
    if *on {
        dl.add_rect([bx, by], [bx + box_sz, by + box_sz], col(accent()))
            .filled(true)
            .rounding(px(4.0))
            .build();
        let pt = |fx: f32, fy: f32| [bx + box_sz * fx, by + box_sz * fy];
        dl.add_line(pt(0.24, 0.52), pt(0.42, 0.72), col(WHITE)).thickness(px(2.0)).build();
        dl.add_line(pt(0.42, 0.72), pt(0.76, 0.28), col(WHITE)).thickness(px(2.0)).build();
    } else {
        let bc = if hovered { DIM } else { CHECK_OFF };
        dl.add_rect([bx, by], [bx + box_sz, by + box_sz], col(bc))
            .thickness(px(1.6))
            .rounding(px(4.0))
            .build();
    }
    let tc = if *on { TEXT } else { DIM };
    dl.add_text([bx + box_sz + px(11.0), start[1] + (row_h - px(16.0)) / 2.0], col(tc), label);
    clicked
}

/// A module toggle row: an Enigma checkbox plus a faint, right-aligned keybind
/// chip. Right-click the row to (re)bind it. Returns whether it toggled.
fn module(ui: &Ui, shared: &mut Shared, id: ModuleId) -> bool {
    let start = ui.cursor_screen_pos();
    let avail = ui.content_region_avail()[0];
    let mut on = id.get(&shared.cfg);
    let changed = enigma_check(ui, id.label(), &mut on, avail);
    if changed {
        id.set(&mut shared.cfg, on);
    }
    if ui.is_item_hovered() && ui.is_mouse_clicked(MouseButton::Right) {
        shared.binding = Some(BindTarget::Module(id));
    }
    let target = BindTarget::Module(id);
    let listening = shared.binding == Some(target);
    let klabel = if listening {
        Some("...".to_string())
    } else {
        shared.bind_for(target).map(key_name)
    };
    if let Some(k) = klabel {
        let txt = format!("[{k}]");
        let tw = ui.calc_text_size(&txt)[0];
        let dl = ui.get_window_draw_list();
        dl.add_text(
            [start[0] + avail - tw, start[1] + px(5.0)],
            col(if listening { accent() } else { DIM }),
            &txt,
        );
    }
    changed
}

/// An indented, Enigma-style slider: label and value on one line above a pink
/// fill track with a round knob. Drag anywhere on it.
fn slider(ui: &Ui, label: &str, v: &mut f32, min: f32, max: f32) {
    ui.indent_by(px(16.0));
    let width = (ui.content_region_avail()[0] - px(16.0)).max(px(80.0));
    let label_h = px(16.0);
    let track_h = px(5.0);
    let start = ui.cursor_screen_pos();
    let _held = ui.invisible_button(format!("##sl_{label}"), [width, label_h + px(9.0) + track_h]);
    if ui.is_item_active() {
        let mx = ui.io().mouse_pos[0];
        let t = ((mx - start[0]) / width).clamp(0.0, 1.0);
        *v = min + (max - min) * t;
    }
    let t = ((*v - min) / (max - min)).clamp(0.0, 1.0);
    let disp = label.split("##").next().unwrap_or(label);
    let val = if max - min > 20.0 {
        format!("{:.0}", *v)
    } else {
        format!("{:.2}", *v)
    };
    let dl = ui.get_window_draw_list();
    dl.add_text([start[0], start[1]], col(DIM), disp);
    let vw = ui.calc_text_size(&val)[0];
    dl.add_text([start[0] + width - vw, start[1]], col(TEXT), &val);
    let ty = start[1] + label_h + px(7.0);
    dl.add_rect([start[0], ty], [start[0] + width, ty + track_h], col(rgba(255, 255, 255, 20)))
        .filled(true)
        .rounding(track_h / 2.0)
        .build();
    dl.add_rect([start[0], ty], [start[0] + width * t, ty + track_h], col(accent()))
        .filled(true)
        .rounding(track_h / 2.0)
        .build();
    dl.add_circle([start[0] + width * t, ty + track_h / 2.0], px(5.0), col(WHITE)).filled(true).build();
    ui.unindent_by(px(16.0));
}

/// An indented enum picker: a small grey caption over a dark rounded dropdown.
fn mode_combo<T: Copy + PartialEq>(
    ui: &Ui,
    label: &str,
    value: &mut T,
    all: &[T],
    name: impl Fn(T) -> &'static str,
) {
    let mut idx = all.iter().position(|m| *m == *value).unwrap_or(0);
    let items: Vec<&str> = all.iter().map(|m| name(*m)).collect();
    ui.indent_by(px(16.0));
    colored(ui, label.split("##").next().unwrap_or(label), DIM);
    ui.set_next_item_width((ui.content_region_avail()[0] - px(16.0)).max(px(80.0)));
    if ui.combo_simple_string(format!("##{label}"), &mut idx, &items) {
        *value = all[idx];
    }
    ui.unindent_by(px(16.0));
}

/// An indented Enigma sub-checkbox.
fn sub(ui: &Ui, label: &str, v: &mut bool) {
    ui.indent_by(px(16.0));
    let avail = ui.content_region_avail()[0];
    enigma_check(ui, label, v, avail);
    ui.unindent_by(px(16.0));
}

/// A groupbox-style section header: a title over a thin divider (Enigma look).
fn section(ui: &Ui, title: &str) {
    ui.dummy([0.0, px(4.0)]);
    let start = ui.cursor_screen_pos();
    let w = ui.content_region_avail()[0];
    let dl = ui.get_window_draw_list();
    dl.add_text([start[0], start[1]], col(TEXT), title);
    let y = start[1] + px(20.0);
    dl.add_line([start[0], y], [start[0] + w, y], col(BORDER)).build();
    drop(dl);
    ui.dummy([0.0, px(28.0)]);
}

/// A "label ....... [swatch]" row whose swatch opens a colour picker on click.
fn color_row(ui: &Ui, label: &str, c: &mut [f32; 4]) {
    let start = ui.cursor_screen_pos();
    let w = ui.content_region_avail()[0];
    {
        let dl = ui.get_window_draw_list();
        dl.add_text([start[0], start[1] + px(5.0)], col(DIM), label);
    }
    ui.set_cursor_screen_pos([start[0] + w - px(28.0), start[1]]);
    let _ = ui
        .color_edit4_config(format!("##c_{label}"), c)
        .inputs(false)
        .alpha(true)
        .build();
    ui.set_cursor_screen_pos([start[0], start[1] + px(27.0)]);
}

/// A rebind row: label on the left, the current key as a pill on the right.
/// Click to start listening for a new key.
fn key_bind_row(ui: &Ui, shared: &mut Shared, target: BindTarget, label: &str, current: u32) {
    let start = ui.cursor_screen_pos();
    let w = ui.content_region_avail()[0];
    let row_h = px(27.0);
    if ui.invisible_button(format!("##kb_{label}"), [w, row_h]) {
        shared.binding = Some(target);
    }
    let hovered = ui.is_item_hovered();
    let listening = shared.binding == Some(target);
    let dl = ui.get_window_draw_list();
    if hovered {
        dl.add_rect(
            [start[0] - px(6.0), start[1]],
            [start[0] + w + px(6.0), start[1] + row_h],
            col(ROW_HOVER),
        )
        .filled(true)
        .rounding(px(4.0))
        .build();
    }
    dl.add_text([start[0], start[1] + px(6.0)], col(DIM), label);
    let key = if listening {
        "press a key".to_string()
    } else {
        key_name(current)
    };
    let kw = ui.calc_text_size(&key)[0];
    let pill_x = start[0] + w - kw - px(16.0);
    dl.add_rect(
        [pill_x, start[1] + px(2.0)],
        [start[0] + w, start[1] + px(24.0)],
        col(rgba(255, 255, 255, 14)),
    )
    .filled(true)
    .rounding(px(5.0))
    .build();
    dl.add_text(
        [pill_x + px(8.0), start[1] + px(5.0)],
        col(if listening { accent() } else { TEXT }),
        &key,
    );
}

/// A row of preset accent swatches, plus the current one ringed in white.
fn preset_swatches(ui: &Ui, shared: &mut Shared) {
    const PRESETS: &[[f32; 4]] = &[
        [0.718, 0.804, 0.831, 1.0], // pale steel #B7CDD4 (default)
        [0.925, 0.243, 0.557, 1.0], // pink
        [0.30, 0.80, 0.95, 1.0],    // cyan
        [0.40, 0.85, 0.50, 1.0],    // green
        [0.62, 0.48, 1.0, 1.0],     // purple
        [0.98, 0.62, 0.25, 1.0],    // orange
        [0.95, 0.30, 0.32, 1.0],    // red
        [0.30, 0.55, 0.98, 1.0],    // blue
        [0.96, 0.84, 0.30, 1.0],    // yellow
    ];
    let start = ui.cursor_screen_pos();
    let sz = px(20.0);
    let gap = px(8.0);
    let ring = px(1.5);
    for (i, p) in PRESETS.iter().enumerate() {
        let x = start[0] + i as f32 * (sz + gap);
        ui.set_cursor_screen_pos([x, start[1]]);
        if ui.invisible_button(format!("##preset{i}"), [sz, sz]) {
            shared.cfg.accent = *p;
        }
        let hov = ui.is_item_hovered();
        let dl = ui.get_window_draw_list();
        dl.add_rect([x, start[1]], [x + sz, start[1] + sz], col(*p))
            .filled(true)
            .rounding(px(5.0))
            .build();
        if hov || shared.cfg.accent == *p {
            dl.add_rect(
                [x - ring, start[1] - ring],
                [x + sz + ring, start[1] + sz + ring],
                col(WHITE),
            )
            .thickness(ring)
            .rounding(px(6.0))
            .build();
        }
    }
    ui.set_cursor_screen_pos([start[0], start[1] + sz + px(10.0)]);
}

/// The global UI-size slider whose value is only committed when the mouse is
/// released, so the whole UI does not re-layout under the cursor while dragging.
fn scale_slider(ui: &Ui, scale: &mut f32) {
    use std::cell::Cell;
    thread_local! {
        static DRAG: Cell<Option<f32>> = Cell::new(None);
    }
    let (min, max) = (0.6f32, 2.5f32);
    let width = ui.content_region_avail()[0].max(px(80.0));
    let label_h = px(16.0);
    let track_h = px(5.0);
    let start = ui.cursor_screen_pos();
    let _b = ui.invisible_button("##uiscale", [width, label_h + px(9.0) + track_h]);
    let active = ui.is_item_active();
    let mut shown = DRAG.with(|d| d.get()).unwrap_or(*scale);
    if active {
        let mx = ui.io().mouse_pos[0];
        let t = ((mx - start[0]) / width).clamp(0.0, 1.0);
        shown = min + (max - min) * t;
        DRAG.with(|d| d.set(Some(shown)));
    } else if let Some(v) = DRAG.with(|d| d.get()) {
        // Released: commit once.
        *scale = v;
        DRAG.with(|d| d.set(None));
        shown = v;
    }
    let t = ((shown - min) / (max - min)).clamp(0.0, 1.0);
    let dl = ui.get_window_draw_list();
    dl.add_text([start[0], start[1]], col(DIM), "UI size");
    let val = format!("{shown:.2}x");
    let vw = ui.calc_text_size(&val)[0];
    dl.add_text([start[0] + width - vw, start[1]], col(TEXT), &val);
    let ty = start[1] + label_h + px(7.0);
    dl.add_rect([start[0], ty], [start[0] + width, ty + track_h], col(rgba(255, 255, 255, 20)))
        .filled(true)
        .rounding(track_h / 2.0)
        .build();
    dl.add_rect([start[0], ty], [start[0] + width * t, ty + track_h], col(accent()))
        .filled(true)
        .rounding(track_h / 2.0)
        .build();
    dl.add_circle([start[0] + width * t, ty + track_h / 2.0], px(5.0), col(WHITE))
        .filled(true)
        .build();
}

/// Draw an entity box in the chosen style, shared by the in-world ESP and the
/// live preview so they always match.
fn draw_box(dl: &DrawListMut, a: [f32; 2], b: [f32; 2], color: [f32; 4], style: BoxStyle, fill: bool) {
    match style {
        BoxStyle::Corners => {
            let lx = (b[0] - a[0]) * 0.28;
            let ly = (b[1] - a[1]) * 0.22;
            let c = col(color);
            for &(cx, cy, sx, sy) in &[
                (a[0], a[1], 1.0f32, 1.0f32),
                (b[0], a[1], -1.0, 1.0),
                (a[0], b[1], 1.0, -1.0),
                (b[0], b[1], -1.0, -1.0),
            ] {
                dl.add_line([cx, cy], [cx + lx * sx, cy], c).thickness(1.5).build();
                dl.add_line([cx, cy], [cx, cy + ly * sy], c).thickness(1.5).build();
            }
        }
        _ => {
            if fill || style == BoxStyle::Filled {
                dl.add_rect(a, b, col([color[0], color[1], color[2], 0.13])).filled(true).build();
            }
            dl.add_rect([a[0] - 1.0, a[1] - 1.0], [b[0] + 1.0, b[1] + 1.0], col(rgba(0, 0, 0, 150)))
                .build();
            dl.add_rect(a, b, col(color)).build();
        }
    }
}

/// Draw a front-facing player from a 64×64 skin texture.
///
/// The figure is 16 skin pixels wide (arm, body, arm) and 32 tall (head, torso,
/// legs); `s` is how many screen pixels one skin pixel covers. Parts are laid
/// out back to front so the hat layer lands on top, and a slim skin narrows the
/// arms to three pixels without moving the body.
fn draw_player_skin(dl: &DrawListMut, texture: u32, slim: bool, fx: f32, fy: f32, s: f32) {
    let id = TextureId::new(texture as usize);
    let arm = if slim { 3.0f32 } else { 4.0 };
    // (dest x, dest y, width, height, uv x, uv y) in skin pixels.
    let parts: [(f32, f32, f32, f32, f32, f32); 6] = [
        (4.0, 20.0, 4.0, 12.0, 4.0, 20.0),    // right leg
        (8.0, 20.0, 4.0, 12.0, 20.0, 52.0),   // left leg
        (4.0, 8.0, 8.0, 12.0, 20.0, 20.0),    // body
        (4.0 - arm, 8.0, arm, 12.0, 44.0, 20.0), // right arm
        (12.0, 8.0, arm, 12.0, 36.0, 52.0),   // left arm
        (4.0, 0.0, 8.0, 8.0, 8.0, 8.0),       // head
    ];
    for (dx, dy, w, h, ux, uy) in parts {
        skin_part(dl, id, fx + dx * s, fy + dy * s, w * s, h * s, ux, uy, w, h);
    }
    // The hat layer sits over the face.
    skin_part(dl, id, fx + 4.0 * s, fy, 8.0 * s, 8.0 * s, 40.0, 8.0, 8.0, 8.0);
}

/// One rectangle of a skin texture, addressed in skin pixels.
#[allow(clippy::too_many_arguments)]
fn skin_part(
    dl: &DrawListMut,
    id: TextureId,
    x: f32,
    y: f32,
    w: f32,
    h: f32,
    ux: f32,
    uy: f32,
    uw: f32,
    uh: f32,
) {
    const SHEET: f32 = 64.0;
    dl.add_image(id, [x, y], [x + w, y + h])
        .uv_min([ux / SHEET, uy / SHEET])
        .uv_max([(ux + uw) / SHEET, (uy + uh) / SHEET])
        .build();
}

// ---- Settings pane ---------------------------------------------------------

fn settings(ui: &Ui, shared: &mut Shared) {
    section(ui, "Appearance");
    color_row(ui, "Accent", &mut shared.cfg.accent);
    ui.dummy([0.0, px(2.0)]);
    preset_swatches(ui, shared);
    ui.dummy([0.0, px(4.0)]);
    scale_slider(ui, &mut shared.cfg.ui_scale);

    section(ui, "Keybinds");
    let mk = shared.cfg.menu_key;
    key_bind_row(ui, shared, BindTarget::MenuToggle, "Open menu", mk);
    let pk = shared.bind_for(BindTarget::Panic).unwrap_or(0);
    key_bind_row(ui, shared, BindTarget::Panic, "Panic", pk);

    section(ui, "Config");
    if ui.button("Save") {
        shared.status = crate::config::save(shared).unwrap_or_else(|e| e);
    }
    ui.same_line();
    if ui.button("Load") {
        shared.status = match crate::config::load(shared) {
            Ok(()) => "loaded".into(),
            Err(e) => e,
        };
    }
    ui.same_line();
    if ui.button("Unload") {
        shared.eject = true;
    }
    ui.dummy([0.0, 4.0]);
    colored(ui, &shared.status.clone(), DIM);
}

// ---- ESP preview panel -----------------------------------------------------

/// A live preview of the ESP: a sample player rendered with the current box
/// style, colours and toggles, updating as they are changed — Enigma's preview.
fn esp_preview(ui: &Ui, shared: &Shared, width: f32) {
    ui.child_window("##esppreview")
        .size([width, 0.0])
        .build(|| {
            {
                let p = ui.cursor_screen_pos();
                let w = ui.content_region_avail()[0];
                let dl = ui.get_window_draw_list();
                dl.add_circle([p[0] + px(5.0), p[1] + px(8.0)], px(4.0), col(accent()))
                    .filled(true)
                    .build();
                dl.add_text([p[0] + px(18.0), p[1] + px(1.0)], col(TEXT), "ESP Preview");
                let y = p[1] + px(26.0);
                dl.add_line([p[0], y], [p[0] + w, y], col(accent()))
                    .thickness(1.0)
                    .build();
            }
            ui.dummy([0.0, px(34.0)]);

            let e = &shared.cfg.esp;
            let p = ui.cursor_screen_pos();
            let w = ui.content_region_avail()[0];
            let h = (ui.content_region_avail()[1] - 6.0).max(160.0);
            let dl = ui.get_window_draw_list();
            // Canvas: dark panel + faint grid.
            dl.add_rect([p[0], p[1]], [p[0] + w, p[1] + h], col([0.055, 0.058, 0.072, 1.0]))
                .filled(true)
                .rounding(6.0)
                .build();
            let grid = col(rgba(255, 255, 255, 8));
            let step = px(26.0);
            let mut gx = p[0] + step;
            while gx < p[0] + w {
                dl.add_line([gx, p[1]], [gx, p[1] + h], grid).build();
                gx += step;
            }
            let mut gy = p[1] + step;
            while gy < p[1] + h {
                dl.add_line([p[0], gy], [p[0] + w, gy], grid).build();
                gy += step;
            }
            dl.add_rect([p[0], p[1]], [p[0] + w, p[1] + h], col(BORDER)).rounding(6.0).build();

            // The sample player is your actual skin, drawn straight from the GL
            // texture the game already has resident. A figure is 16 skin pixels
            // across and 32 tall, so one scale factor places every part.
            let fig_h = h * 0.62;
            let s = fig_h / 32.0;
            let fig_w = 16.0 * s;
            let fx = p[0] + (w - fig_w) * 0.5;
            let fy = p[1] + h * 0.17;
            let (bx0, bx1, by0, by1) = (fx, fx + fig_w, fy, fy + fig_h);

            let g = &shared.game;
            if g.skin_texture != 0 {
                draw_player_skin(&dl, g.skin_texture, g.skin_slim, fx, fy, s);
            } else {
                // Before the skin resolves, a plain stand-in of the same size.
                dl.add_rect(
                    [fx + 4.0 * s, fy + 8.0 * s],
                    [fx + 12.0 * s, by1],
                    col([0.32, 0.38, 0.50, 0.55]),
                )
                .filled(true)
                .build();
                dl.add_rect(
                    [fx + 4.0 * s, fy],
                    [fx + 12.0 * s, fy + 8.0 * s],
                    col([0.34, 0.40, 0.52, 0.6]),
                )
                .filled(true)
                .build();
            }

            let color = e.color_player;
            if e.boxes {
                draw_box(&dl, [bx0, by0], [bx1, by1], color, e.box_style, e.box_fill);
            }
            if e.tracers {
                dl.add_line([p[0] + w * 0.5, p[1] + h], [(bx0 + bx1) / 2.0, by1], col(color))
                    .thickness(1.2)
                    .build();
            }
            if e.health_bars {
                let frac = 0.7;
                let x0 = bx0 - 6.0;
                dl.add_rect([x0 - 1.0, by0], [x0 + 1.5, by1], col(rgba(0, 0, 0, 150)))
                    .filled(true)
                    .build();
                let top = by1 - (by1 - by0) * frac;
                let hc = rgb((255.0 * (1.0 - frac)) as u8, (220.0 * frac) as u8, 80);
                dl.add_rect([x0 - 1.0, top], [x0 + 1.5, by1], col(hc)).filled(true).build();
            }
            if e.nametags {
                let who = if g.player_name.is_empty() {
                    "Player"
                } else {
                    g.player_name.as_str()
                };
                let name = format!("{who}  12m");
                let tw = ui.calc_text_size(&name)[0];
                let cx = (bx0 + bx1) / 2.0;
                dl.add_rect(
                    [cx - tw * 0.5 - 3.0, by0 - 16.0],
                    [cx + tw * 0.5 + 3.0, by0 - 2.0],
                    col(rgba(0, 0, 0, 150)),
                )
                .filled(true)
                .rounding(2.0)
                .build();
                dl.add_text([cx - tw * 0.5, by0 - 15.0], col(color), name);
                if e.show_gear {
                    let gear = "sword  [4a]";
                    let gw = ui.calc_text_size(gear)[0];
                    dl.add_text([cx - gw * 0.5, by0 - 30.0], col(rgb(200, 200, 210)), gear);
                }
            }
            // Caption of what is on.
            let mut on: Vec<&str> = Vec::new();
            if e.boxes {
                on.push("box");
            }
            if e.tracers {
                on.push("tracer");
            }
            if e.nametags {
                on.push("name");
            }
            if e.health_bars {
                on.push("health");
            }
            if e.show_gear {
                on.push("gear");
            }
            let cap = if on.is_empty() { "nothing shown".to_string() } else { on.join("  ·  ") };
            dl.add_text([p[0] + px(8.0), p[1] + h - px(18.0)], col(DIM), &cap);
        });
}

// ---- panes -----------------------------------------------------------------

fn combat(ui: &Ui, shared: &mut Shared) {
    module(ui, shared, ModuleId::KillAura);
    if shared.cfg.combat.kill_aura {
        let c = &mut shared.cfg.combat;
        mode_combo(ui, "target", &mut c.aura_target, &AuraTarget::ALL, |m| m.label());
        slider(ui, "range", &mut c.aura_range, 2.0, 6.0);
        if !c.aura_cooldown {
            slider(ui, "cps", &mut c.aura_cps, 1.0, 20.0);
        }
        sub(ui, "wait for cooldown", &mut c.aura_cooldown);
        sub(ui, "rotate", &mut c.aura_rotate);
        sub(ui, "through walls", &mut c.aura_through_walls);
        sub(ui, "players", &mut c.aura_players);
        sub(ui, "mobs", &mut c.aura_mobs);
        sub(ui, "animals", &mut c.aura_animals);
    }
    module(ui, shared, ModuleId::Aimbot);
    if shared.cfg.combat.aimbot {
        let c = &mut shared.cfg.combat;
        mode_combo(ui, "mode##aim", &mut c.aim_mode, &AimMode::ALL, |m| m.label());
        slider(ui, "fov", &mut c.aim_fov, 5.0, 180.0);
        slider(ui, "smoothing", &mut c.aim_speed, 0.05, 1.0);
    }
    module(ui, shared, ModuleId::TriggerBot);
    if shared.cfg.combat.trigger_bot {
        slider(ui, "delay", &mut shared.cfg.combat.trigger_delay, 0.0, 0.5);
    }
    module(ui, shared, ModuleId::AutoDodge);
    if shared.cfg.combat.auto_dodge {
        let c = &mut shared.cfg.combat;
        slider(ui, "range##dodge", &mut c.dodge_range, 3.0, 16.0);
        slider(ui, "speed##dodge", &mut c.dodge_speed, 0.1, 0.5);
        sub(ui, "arrows", &mut c.dodge_arrows);
        sub(ui, "avoid ledges", &mut c.dodge_cliffs);
    }
    module(ui, shared, ModuleId::Reach);
    if shared.cfg.combat.reach {
        slider(ui, "blocks", &mut shared.cfg.combat.reach_distance, 3.0, 6.0);
    }
    module(ui, shared, ModuleId::AutoClicker);
    if shared.cfg.combat.auto_clicker {
        let c = &mut shared.cfg.combat;
        slider(ui, "cps##click", &mut c.click_cps, 1.0, 20.0);
        slider(ui, "jitter", &mut c.click_jitter, 0.0, 1.0);
    }
    module(ui, shared, ModuleId::Criticals);
    if shared.cfg.combat.criticals {
        slider(ui, "hop", &mut shared.cfg.combat.crit_hop, 0.05, 0.42);
    }
    module(ui, shared, ModuleId::SprintReset);
    module(ui, shared, ModuleId::Hitbox);
    if shared.cfg.combat.hitbox {
        slider(ui, "expand", &mut shared.cfg.combat.hitbox_expand, 0.0, 1.0);
    }
    module(ui, shared, ModuleId::BowAimbot);
    module(ui, shared, ModuleId::Backtrack);
    if shared.cfg.combat.backtrack {
        slider(ui, "ms", &mut shared.cfg.combat.backtrack_ms, 20.0, 400.0);
    }
    module(ui, shared, ModuleId::AutoTotem);
    if shared.cfg.combat.auto_totem {
        mode_combo(ui, "mode##totem", &mut shared.cfg.combat.totem_mode, &TotemMode::ALL, |m| {
            m.label()
        });
    }
    module(ui, shared, ModuleId::AutoShield);
    module(ui, shared, ModuleId::AutoCrystal);
    if shared.cfg.combat.auto_crystal {
        slider(ui, "range##crystal", &mut shared.cfg.combat.crystal_range, 3.0, 6.0);
        slider(ui, "delay##crystal", &mut shared.cfg.combat.crystal_delay, 0.0, 0.5);
    }
    module(ui, shared, ModuleId::AutoMace);
    module(ui, shared, ModuleId::AntiKnockback);
    if shared.cfg.combat.anti_knockback {
        let c = &mut shared.cfg.combat;
        slider(ui, "horizontal", &mut c.kb_horizontal, 0.0, 1.0);
        slider(ui, "vertical", &mut c.kb_vertical, 0.0, 1.0);
    }
}

fn movement(ui: &Ui, shared: &mut Shared) {
    module(ui, shared, ModuleId::Fly);
    if shared.cfg.movement.fly {
        let c = &mut shared.cfg.movement;
        mode_combo(ui, "mode##fly", &mut c.fly_mode, &FlyMode::ALL, |m| m.label());
        match c.fly_mode {
            FlyMode::Teleport => {
                slider(ui, "step", &mut c.fly_step, 0.5, 8.0);
                slider(ui, "interval", &mut c.fly_step_interval, 0.05, 1.0);
            }
            FlyMode::Creative => slider(ui, "speed##fly", &mut c.fly_speed, 0.01, 0.6),
            _ => slider(ui, "speed##fly", &mut c.fly_speed, 0.05, 2.0),
        }
        sub(ui, "anti-kick", &mut c.fly_anti_kick);
        if c.fly_anti_kick {
            slider(ui, "dip interval", &mut c.fly_dip_interval, 0.5, 4.0);
        }
    }
    module(ui, shared, ModuleId::Speed);
    if shared.cfg.movement.speed {
        let c = &mut shared.cfg.movement;
        mode_combo(ui, "mode##spd", &mut c.speed_mode, &SpeedMode::ALL, |m| m.label());
        slider(ui, "amount", &mut c.speed_value, 0.05, 1.0);
    }
    module(ui, shared, ModuleId::Bhop);
    module(ui, shared, ModuleId::Sprint);
    module(ui, shared, ModuleId::NoFall);
    module(ui, shared, ModuleId::Noclip);
    module(ui, shared, ModuleId::Jesus);
    module(ui, shared, ModuleId::Spider);
    if shared.cfg.movement.spider {
        slider(ui, "grip", &mut shared.cfg.movement.spider_power, 0.1, 0.6);
    }
    module(ui, shared, ModuleId::Step);
    if shared.cfg.movement.step {
        slider(ui, "height", &mut shared.cfg.movement.step_height, 0.6, 3.0);
    }
    module(ui, shared, ModuleId::HighJump);
    if shared.cfg.movement.jump_power {
        slider(ui, "power##jump", &mut shared.cfg.movement.jump_multiplier, 1.0, 4.0);
    }
    module(ui, shared, ModuleId::Jetpack);
    if shared.cfg.movement.jetpack {
        slider(ui, "power##jet", &mut shared.cfg.movement.jetpack_power, 0.1, 1.5);
    }
    module(ui, shared, ModuleId::Freecam);
    if shared.cfg.movement.freecam {
        slider(ui, "speed##cam", &mut shared.cfg.movement.freecam_speed, 0.1, 3.0);
    }
    module(ui, shared, ModuleId::Blink);
    module(ui, shared, ModuleId::Safewalk);
    module(ui, shared, ModuleId::Timer);
    if shared.cfg.movement.timer {
        slider(ui, "speed##timer", &mut shared.cfg.movement.timer_speed, 0.5, 5.0);
    }
    ui.separator();
    let mut limit = shared.cfg.movement.speed_limit;
    if ui.checkbox("stay within server limits", &mut limit) {
        shared.cfg.movement.speed_limit = limit;
    }
}

fn world(ui: &Ui, shared: &mut Shared) {
    module(ui, shared, ModuleId::NoCooldown);
    module(ui, shared, ModuleId::FastPlace);
    module(ui, shared, ModuleId::BlockReach);
    if shared.cfg.building.block_reach {
        slider(ui, "blocks##br", &mut shared.cfg.building.block_reach_dist, 4.0, 6.0);
    }
    module(ui, shared, ModuleId::AirPlace);
    if shared.cfg.building.air_place {
        slider(ui, "delay##place", &mut shared.cfg.building.place_delay, 0.0, 0.5);
    }
    module(ui, shared, ModuleId::AutoBuild);
}

fn esp(ui: &Ui, shared: &mut Shared) {
    module(ui, shared, ModuleId::EspPlayers);
    module(ui, shared, ModuleId::EspMobs);
    module(ui, shared, ModuleId::EspAnimals);
    module(ui, shared, ModuleId::EspItems);
    module(ui, shared, ModuleId::EspContainers);
    module(ui, shared, ModuleId::BaseFinder);
    if shared.cfg.esp.base_finder {
        slider(ui, "chunks", &mut shared.cfg.esp.base_chunk_radius, 2.0, 16.0);
    }
    module(ui, shared, ModuleId::Xray);
    if shared.cfg.esp.xray {
        shared
            .cfg
            .esp
            .xray_selected
            .resize(crate::blocks::ORE_GROUPS.len(), false);
        ui.indent_by(16.0);
        for (i, (label, _)) in crate::blocks::ORE_GROUPS.iter().enumerate() {
            let mut on = shared.cfg.esp.xray_selected[i];
            let _t = ui.push_style_color(StyleColor::Text, if on { ore_colour(i) } else { DIM });
            if ui.checkbox(format!("{label}##ore{i}"), &mut on) {
                shared.cfg.esp.xray_selected[i] = on;
            }
        }
        ui.unindent_by(16.0);
        slider(ui, "radius", &mut shared.cfg.esp.block_radius, 8.0, 48.0);
    }
    module(ui, shared, ModuleId::PlayerRadar);
    ui.separator();
    module(ui, shared, ModuleId::GizmoEsp);
    module(ui, shared, ModuleId::EspBoxes);
    module(ui, shared, ModuleId::EspTracers);
    module(ui, shared, ModuleId::EspNametags);
    module(ui, shared, ModuleId::EspHealth);
    module(ui, shared, ModuleId::ShowInvis);
    module(ui, shared, ModuleId::ShowPing);
    module(ui, shared, ModuleId::ShowThreat);
    module(ui, shared, ModuleId::ShowGear);
    module(ui, shared, ModuleId::ServerGhost);
    slider(ui, "range##esp", &mut shared.cfg.esp.distance, 16.0, 256.0);

    section(ui, "Box style");
    mode_combo(ui, "box##style", &mut shared.cfg.esp.box_style, &BoxStyle::ALL, |m| m.label());
    sub(ui, "translucent fill", &mut shared.cfg.esp.box_fill);

    section(ui, "Colors");
    color_row(ui, "players", &mut shared.cfg.esp.color_player);
    color_row(ui, "mobs", &mut shared.cfg.esp.color_mob);
    color_row(ui, "animals", &mut shared.cfg.esp.color_animal);
    color_row(ui, "items", &mut shared.cfg.esp.color_item);
    color_row(ui, "can reach you", &mut shared.cfg.esp.color_threat);
    color_row(ui, "invisible", &mut shared.cfg.esp.color_invis);
}

fn visuals(ui: &Ui, shared: &mut Shared) {
    module(ui, shared, ModuleId::Fullbright);
    module(ui, shared, ModuleId::NoCulling);
    module(ui, shared, ModuleId::ViewDistance);
    if shared.cfg.visuals.view_distance {
        slider(ui, "chunks##vd", &mut shared.cfg.visuals.view_distance_chunks, 8.0, 32.0);
    }
    module(ui, shared, ModuleId::FastChunks);
    if shared.cfg.visuals.fast_chunks {
        slider(ui, "per tick", &mut shared.cfg.visuals.fast_chunks_rate, 5.0, 200.0);
    }
    module(ui, shared, ModuleId::NoFog);
    module(ui, shared, ModuleId::NoWeather);
    module(ui, shared, ModuleId::NoHurtCam);
    module(ui, shared, ModuleId::NoBob);
    module(ui, shared, ModuleId::CustomFov);
    if shared.cfg.visuals.fov {
        slider(ui, "fov", &mut shared.cfg.visuals.fov_value, 30.0, 140.0);
    }
    ui.separator();
    module(ui, shared, ModuleId::Watermark);
    module(ui, shared, ModuleId::HudCoords);
    module(ui, shared, ModuleId::HudModules);
}

fn misc(ui: &Ui, shared: &mut Shared) {
    module(ui, shared, ModuleId::AutoRespawn);
    if module(ui, shared, ModuleId::HideCapture) {
        crate::capture::apply(shared.cfg.misc.hide_from_capture);
    }
    module(ui, shared, ModuleId::PacketLog);
    if shared.cfg.misc.packet_log && !shared.game.server_brand.is_empty() {
        ui.indent_by(16.0);
        colored(ui, &format!("brand: {}", shared.game.server_brand), DIM);
        ui.unindent_by(16.0);
    }

    ui.separator();
    // Panic and rebind.
    let listening = shared.binding == Some(BindTarget::Panic);
    let pk = if listening {
        "...".into()
    } else {
        shared
            .bind_for(BindTarget::Panic)
            .map(key_name)
            .unwrap_or_else(|| "unbound".into())
    };
    if ui.button(format!("Panic  [{pk}]")) {
        shared.panic_off();
    }
    if ui.is_item_hovered() && ui.is_mouse_clicked(MouseButton::Right) {
        shared.binding = Some(BindTarget::Panic);
    }
    ui.same_line();
    if ui.button("Save") {
        shared.status = match crate::config::save(shared) {
            Ok(_) => "saved".into(),
            Err(e) => e,
        };
    }
    ui.same_line();
    if ui.button("Load") {
        shared.status = match crate::config::load(shared) {
            Ok(()) => "loaded".into(),
            Err(e) => e,
        };
    }

    ui.set_next_item_width(150.0);
    let _ = ui.slider("ui scale", 0.6, 2.5, &mut shared.cfg.ui_scale);

    ui.separator();
    if ui.button("Unload") {
        shared.eject = true;
    }
    ui.same_line();
    colored(ui, &shared.status.clone(), DIM);

    // Status readout.
    let g = shared.game.clone();
    let world = if !g.in_world {
        "no world"
    } else if g.single_player {
        "single player"
    } else {
        "server"
    };
    colored(ui, &format!("{world}  {:.0} fps", g.fps), DIM);
    colored(ui, &format!("{:.0} {:.0} {:.0}", g.pos.0, g.pos.1, g.pos.2), DIM);
    colored(
        ui,
        &format!("{} entities  {} chunks", g.targets.len(), g.loaded_chunks),
        DIM,
    );

    if !shared.missing.is_empty() {
        ui.separator();
        colored(ui, &format!("{} unresolved", shared.missing.len()), WARN);
        for m in shared.missing.iter().take(8) {
            ui.indent_by(16.0);
            colored(ui, m, WARN);
            ui.unindent_by(16.0);
        }
    }
}

// ---------------------------------------------------------------------------
// in-world overlay (ESP / HUD) via the background draw list
// ---------------------------------------------------------------------------

fn world_overlay(ui: &Ui, shared: &Shared) {
    let cfg = &shared.cfg.esp;
    let g = &shared.game;
    let nothing = g.targets.is_empty()
        && g.blocks.is_empty()
        && g.base_hits.is_empty()
        && g.blips.is_empty()
        && g.ghost.is_none();
    if nothing {
        return;
    }
    let [sw, sh] = ui.io().display_size;
    let view = View::new(g.camera, g.camera_yaw, g.camera_pitch, g.fov, [sw, sh]);
    let dl = ui.get_background_draw_list();

    // Ore / X-Ray blocks first, so entities draw over them.
    for (x, y, z, kind) in g.blocks.iter().take(3000) {
        let crate::state::BlockKind::Ore(group) = kind;
        let min = (*x as f64, *y as f64, *z as f64);
        let max = (min.0 + 1.0, min.1 + 1.0, min.2 + 1.0);
        if let Some((a, b)) = view.project_box(min, max) {
            dl.add_rect(a, b, col(ore_colour(*group))).build();
        }
    }
    // Base finder / containers.
    for hit in g.base_hits.iter().take(400) {
        if hit.distance > cfg.distance.max(64.0) && !hit.is_base {
            continue;
        }
        let min = (hit.x as f64, hit.y as f64, hit.z as f64);
        let max = (min.0 + 1.0, min.1 + 1.0, min.2 + 1.0);
        if let Some((a, b)) = view.project_box(min, max) {
            let c = if hit.is_base { rgb(236, 130, 220) } else { rgb(232, 186, 104) };
            dl.add_rect(a, b, col(c)).build();
            if hit.is_base {
                dl.add_text([a[0], a[1] - 12.0], col(c), format!("{} {:.0}m", hit.label, hit.distance));
            }
        }
    }
    // Where the server thinks you are.
    if let Some((pos, distance)) = g.ghost {
        let min = (pos.0 - 0.3, pos.1, pos.2 - 0.3);
        let max = (pos.0 + 0.3, pos.1 + 1.8, pos.2 + 0.3);
        if let Some((a, b)) = view.project_box(min, max) {
            let c = if distance > 1.0 { rgb(255, 85, 85) } else { rgb(120, 180, 255) };
            dl.add_rect(a, b, col(c)).build();
            dl.add_text([a[0], a[1] - 12.0], col(c), format!("server you {distance:.1}m"));
        }
    }
    // Radar blips.
    for (x, y, z, coarse) in &g.blips {
        let half = if *coarse { 8.0 } else { 0.4 };
        let yy = if y.is_nan() { g.camera.1 } else { *y };
        let min = (x - half, yy - 1.0, z - half);
        let max = (x + half, yy + 2.0, z + half);
        if let Some((a, b)) = view.project_box(min, max) {
            dl.add_rect(a, b, col(rgb(255, 120, 255))).build();
        }
    }
    // Entities.
    for t in g.targets.iter().take(192) {
        let Some((a, b)) = view.project_box(t.min, t.max) else {
            continue;
        };
        let colour = if t.can_reach_you {
            cfg.color_threat
        } else if t.invisible {
            cfg.color_invis
        } else {
            kind_colour(cfg, t.kind)
        };
        if cfg.boxes {
            draw_box(&dl, a, b, colour, cfg.box_style, cfg.box_fill);
        }
        if cfg.tracers {
            dl.add_line([sw * 0.5, sh], [(a[0] + b[0]) * 0.5, b[1]], col(colour)).build();
        }
        if cfg.health_bars && t.max_health > 0.0 {
            let frac = (t.health / t.max_health).clamp(0.0, 1.0);
            let x0 = a[0] - 5.0;
            dl.add_rect([x0 - 1.0, a[1]], [x0 + 1.5, b[1]], col(rgba(0, 0, 0, 150)))
                .filled(true)
                .build();
            let top = b[1] - (b[1] - a[1]) * frac;
            let hc = rgb((255.0 * (1.0 - frac)) as u8, (220.0 * frac) as u8, 80);
            dl.add_rect([x0 - 1.0, top], [x0 + 1.5, b[1]], col(hc)).filled(true).build();
        }
        if cfg.nametags && !t.name.is_empty() {
            let mut label = format!("{} {:.0}m", t.name, t.distance);
            if t.ping >= 0 {
                label.push_str(&format!(" {}ms", t.ping));
            }
            if t.invisible {
                label.push_str(" *");
            }
            if t.can_reach_you {
                label.push_str(" !");
            }
            let tw = ui.calc_text_size(&label)[0];
            let cx = (a[0] + b[0]) * 0.5;
            // A soft backing plate keeps the label readable against terrain.
            dl.add_rect(
                [cx - tw * 0.5 - 2.0, a[1] - 14.0],
                [cx + tw * 0.5 + 2.0, a[1] - 1.0],
                col(rgba(0, 0, 0, 140)),
            )
            .filled(true)
            .rounding(2.0)
            .build();
            dl.add_text([cx - tw * 0.5, a[1] - 13.0], col(colour), &label);
            if !t.held.is_empty() || t.armor > 0 {
                let mut gear = t.held.clone();
                if t.armor > 0 {
                    gear.push_str(&format!(" [{}a]", t.armor));
                }
                let gw = ui.calc_text_size(&gear)[0];
                dl.add_text([cx - gw * 0.5, a[1] - 26.0], col(rgb(200, 200, 210)), &gear);
            }
        }
    }
}

fn hud(ui: &Ui, shared: &Shared, menu_open: bool) {
    let v = &shared.cfg.visuals;
    let g = &shared.game;
    let dl = ui.get_background_draw_list();
    let [sw, sh] = ui.io().display_size;
    if v.watermark {
        dl.add_text([px(7.0), px(5.0)], col(accent()), "Lodestone");
        if !menu_open {
            dl.add_text([px(7.0), px(21.0)], col(DIM), "[insert]");
        }
    }
    if v.hud_coords && g.in_world {
        dl.add_text(
            [px(7.0), sh - px(18.0)],
            col(TEXT),
            format!("{:.0} {:.0} {:.0}", g.pos.0, g.pos.1, g.pos.2),
        );
    }
    if v.hud_modules {
        let mut y = px(5.0);
        for name in active_modules(&shared.cfg) {
            let w = ui.calc_text_size(&name)[0];
            dl.add_text([sw - w - px(7.0), y], col(accent()), &name);
            y += px(15.0);
        }
    }
}

fn active_modules(cfg: &crate::state::Config) -> Vec<String> {
    let m = &cfg.movement;
    let cb = &cfg.combat;
    let v = &cfg.visuals;
    let e = &cfg.esp;
    let mut out: Vec<String> = Vec::new();
    if m.fly {
        out.push(format!("Fly [{}]", m.fly_mode.label()));
    }
    if m.speed {
        out.push(format!("Speed [{}]", m.speed_mode.label()));
    }
    for (on, name) in [
        (m.freecam, "Freecam"),
        (m.no_fall, "No Fall"),
        (m.jetpack, "Jetpack"),
        (m.sprint, "Auto Sprint"),
        (m.noclip, "Noclip"),
        (m.step, "Step"),
        (m.jump_power, "High Jump"),
        (m.jesus, "Jesus"),
        (m.spider, "Spider"),
        (m.bhop, "Bhop"),
        (m.blink, "Blink"),
        (m.safewalk, "Safewalk"),
        (m.timer, "Timer"),
        (e.base_finder, "Base Finder"),
        (cb.criticals, "Criticals"),
        (cb.sprint_reset, "Sprint Reset"),
        (cb.bow_aimbot, "Bow Aimbot"),
        (cb.backtrack, "Backtrack"),
        (cb.hitbox, "Hitbox"),
        (cb.auto_dodge, "Auto Dodge"),
        (cb.auto_totem, "Auto Totem"),
        (cb.auto_shield, "Auto Shield"),
        (cfg.building.no_cooldown, "No Cooldown"),
        (cfg.building.fast_place, "Fast Place"),
        (cfg.building.block_reach, "Block Reach"),
        (cfg.building.air_place, "Air Place"),
        (cfg.building.auto_build, "Auto Build"),
        (cfg.misc.auto_respawn, "Auto Respawn"),
        (cb.kill_aura, "Kill Aura"),
        (cb.aimbot, "Aimbot"),
        (cb.trigger_bot, "Trigger Bot"),
        (cb.reach, "Reach"),
        (cb.auto_clicker, "Auto Clicker"),
        (cb.anti_knockback, "Anti KB"),
        (e.players || e.mobs || e.animals || e.items || e.containers, "ESP"),
        (e.xray, "X-Ray"),
        (e.player_radar, "Radar"),
        (e.server_ghost, "Server Ghost"),
        (e.gizmo_esp, "3D Boxes"),
        (v.fast_chunks, "Fast Chunks"),
        (v.fullbright, "Fullbright"),
        (v.no_fog, "No Fog"),
        (v.no_hurt_cam, "No Hurt Cam"),
        (v.no_culling, "No Culling"),
        (v.view_distance, "Extend View"),
        (cfg.misc.hide_from_capture, "Hidden"),
    ] {
        if on {
            out.push(name.to_string());
        }
    }
    out
}

fn ore_colour(group: usize) -> [f32; 4] {
    const PALETTE: &[[u8; 3]] = &[
        [108, 222, 226],
        [176, 120, 220],
        [104, 222, 132],
        [240, 208, 96],
        [214, 178, 150],
        [238, 90, 90],
        [96, 132, 232],
        [226, 142, 88],
        [130, 136, 146],
        [228, 224, 214],
        [196, 108, 232],
        [236, 108, 196],
        [232, 186, 104],
    ];
    let c = PALETTE.get(group).copied().unwrap_or([150, 123, 255]);
    rgb(c[0], c[1], c[2])
}

fn kind_colour(cfg: &crate::state::Esp, kind: crate::mc::TargetKind) -> [f32; 4] {
    use crate::mc::TargetKind::*;
    match kind {
        Player => cfg.color_player,
        Mob => cfg.color_mob,
        Animal => cfg.color_animal,
        Item => cfg.color_item,
        Other => DIM,
    }
}

/// World-to-screen for one frame's camera. Minecraft's yaw is zero looking
/// south (+Z) and increases clockwise, so forward is
/// (-sin yaw · cos pitch, -sin pitch, cos yaw · cos pitch).
struct View {
    camera: (f64, f64, f64),
    right: (f64, f64, f64),
    up: (f64, f64, f64),
    forward: (f64, f64, f64),
    half: [f32; 2],
    tan_half_fov: f64,
    aspect: f64,
}

impl View {
    fn new(camera: (f64, f64, f64), yaw: f32, pitch: f32, fov: f32, size: [f32; 2]) -> Self {
        let (sy, cy) = (yaw as f64).to_radians().sin_cos();
        let (sp, cp) = (pitch as f64).to_radians().sin_cos();
        let forward = (-sy * cp, -sp, cy * cp);
        let right = (-forward.2, 0.0, forward.0);
        let rl = (right.0 * right.0 + right.2 * right.2).sqrt().max(1e-9);
        let right = (right.0 / rl, 0.0, right.2 / rl);
        let up = (
            right.1 * forward.2 - right.2 * forward.1,
            right.2 * forward.0 - right.0 * forward.2,
            right.0 * forward.1 - right.1 * forward.0,
        );
        Self {
            camera,
            right,
            up,
            forward,
            half: [size[0] / 2.0, size[1] / 2.0],
            tan_half_fov: ((fov.max(1.0) as f64) / 2.0).to_radians().tan(),
            aspect: (size[0] / size[1].max(1.0)) as f64,
        }
    }

    fn project(&self, p: (f64, f64, f64)) -> Option<[f32; 2]> {
        let d = (p.0 - self.camera.0, p.1 - self.camera.1, p.2 - self.camera.2);
        let z = dot(d, self.forward);
        if z <= 0.05 {
            return None;
        }
        let x = dot(d, self.right);
        let y = dot(d, self.up);
        Some([
            self.half[0] * (1.0 + (x / (z * self.tan_half_fov * self.aspect)) as f32),
            self.half[1] * (1.0 - (y / (z * self.tan_half_fov)) as f32),
        ])
    }

    /// The screen rectangle (min, max) covering all eight corners of a box.
    fn project_box(
        &self,
        min: (f64, f64, f64),
        max: (f64, f64, f64),
    ) -> Option<([f32; 2], [f32; 2])> {
        let mut lo = [f32::MAX; 2];
        let mut hi = [f32::MIN; 2];
        for i in 0..8 {
            let corner = (
                if i & 1 == 0 { min.0 } else { max.0 },
                if i & 2 == 0 { min.1 } else { max.1 },
                if i & 4 == 0 { min.2 } else { max.2 },
            );
            let p = self.project(corner)?;
            lo[0] = lo[0].min(p[0]);
            lo[1] = lo[1].min(p[1]);
            hi[0] = hi[0].max(p[0]);
            hi[1] = hi[1].max(p[1]);
        }
        Some((lo, hi))
    }
}

fn dot(a: (f64, f64, f64), b: (f64, f64, f64)) -> f64 {
    a.0 * b.0 + a.1 * b.1 + a.2 * b.2
}

// ---------------------------------------------------------------------------
// theme + input mapping
// ---------------------------------------------------------------------------

fn theme(style: &mut imgui::Style) {
    // Enigma (CS2): very dark panels, generous rounding, a single hot-pink
    // accent. Most widgets are hand-drawn on the window draw list; these colours
    // cover the built-in dropdowns, buttons, scrollbars and text.
    style.window_rounding = 9.0;
    style.child_rounding = 7.0;
    style.frame_rounding = 6.0;
    style.grab_rounding = 5.0;
    style.popup_rounding = 6.0;
    style.window_border_size = 1.0;
    style.frame_border_size = 0.0;
    style.window_padding = [14.0, 14.0];
    style.frame_padding = [10.0, 6.0];
    style.item_spacing = [8.0, 8.0];
    style.scrollbar_size = 9.0;
    let c = &mut style.colors;
    c[StyleColor::Text as usize] = TEXT;
    c[StyleColor::TextDisabled as usize] = DIM;
    c[StyleColor::WindowBg as usize] = [0.055, 0.055, 0.070, 0.98];
    c[StyleColor::ChildBg as usize] = [0.0, 0.0, 0.0, 0.0];
    c[StyleColor::PopupBg as usize] = [0.075, 0.075, 0.092, 0.99];
    c[StyleColor::Border as usize] = BORDER;
    c[StyleColor::FrameBg as usize] = [0.11, 0.11, 0.13, 1.0];
    c[StyleColor::FrameBgHovered as usize] = [0.15, 0.15, 0.18, 1.0];
    c[StyleColor::FrameBgActive as usize] = [0.18, 0.18, 0.22, 1.0];
    c[StyleColor::CheckMark as usize] = WHITE;
    c[StyleColor::SliderGrab as usize] = accent();
    c[StyleColor::SliderGrabActive as usize] = accent();
    c[StyleColor::Button as usize] = [0.13, 0.13, 0.16, 1.0];
    c[StyleColor::ButtonHovered as usize] = [0.18, 0.18, 0.22, 1.0];
    c[StyleColor::ButtonActive as usize] = accent();
    c[StyleColor::Header as usize] = accent_a(0.10);
    c[StyleColor::HeaderHovered as usize] = [0.30, 0.16, 0.24, 1.0];
    c[StyleColor::HeaderActive as usize] = accent();
    c[StyleColor::Separator as usize] = BORDER;
    c[StyleColor::ScrollbarBg as usize] = [0.0, 0.0, 0.0, 0.0];
    c[StyleColor::ScrollbarGrab as usize] = [0.22, 0.22, 0.27, 1.0];
    c[StyleColor::ScrollbarGrabHovered as usize] = [0.30, 0.30, 0.36, 1.0];
}

/// Map a Win32 virtual-key code to the handful of ImGui keys the menu needs for
/// text editing; module keybinds are handled separately, before input reaches
/// ImGui.
fn vk_to_key(vk: u32) -> Option<Key> {
    Some(match vk {
        0x08 => Key::Backspace,
        0x09 => Key::Tab,
        0x0D => Key::Enter,
        0x1B => Key::Escape,
        0x20 => Key::Space,
        0x25 => Key::LeftArrow,
        0x26 => Key::UpArrow,
        0x27 => Key::RightArrow,
        0x28 => Key::DownArrow,
        0x2E => Key::Delete,
        0x24 => Key::Home,
        0x23 => Key::End,
        _ => return None,
    })
}

/// Short label for a virtual-key code.
pub fn key_name(vk: u32) -> String {
    match vk {
        0x08 => "BKSP".into(),
        0x09 => "TAB".into(),
        0x0D => "ENTER".into(),
        0x10 => "SHIFT".into(),
        0x11 => "CTRL".into(),
        0x12 => "ALT".into(),
        0x14 => "CAPS".into(),
        0x20 => "SPACE".into(),
        0x21 => "PGUP".into(),
        0x22 => "PGDN".into(),
        0x23 => "END".into(),
        0x24 => "HOME".into(),
        0x25 => "LEFT".into(),
        0x26 => "UP".into(),
        0x27 => "RIGHT".into(),
        0x28 => "DOWN".into(),
        0x2D => "INS".into(),
        0x2E => "DEL".into(),
        0x30..=0x39 => ((b'0' + (vk - 0x30) as u8) as char).to_string(),
        0x41..=0x5A => ((b'A' + (vk - 0x41) as u8) as char).to_string(),
        0x60..=0x69 => format!("NUM{}", vk - 0x60),
        0x70..=0x7B => format!("F{}", vk - 0x70 + 1),
        other => format!("{other:#04x}"),
    }
}

// ---------------------------------------------------------------------------
// GL plumbing
// ---------------------------------------------------------------------------

/// Resolve a GL entry point: extensions via wglGetProcAddress, core 1.1 from
/// the opengl32 export table.
#[cfg(windows)]
fn gl_proc(name: &str) -> *const std::ffi::c_void {
    use std::ffi::CString;
    use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
    // SAFETY: opengl32 is loaded; both lookups are by NUL-terminated name and
    // the result is only ever used as a function pointer by glow.
    unsafe {
        let Ok(c) = CString::new(name) else {
            return std::ptr::null();
        };
        let opengl32 = GetModuleHandleA(c"opengl32.dll".as_ptr() as *const u8);
        if opengl32.is_null() {
            return std::ptr::null();
        }
        if let Some(wgl) = GetProcAddress(opengl32, c"wglGetProcAddress".as_ptr() as *const u8) {
            let wgl: unsafe extern "system" fn(*const u8) -> *const std::ffi::c_void =
                std::mem::transmute(wgl);
            let p = wgl(c.as_ptr() as *const u8);
            let bad = p.is_null()
                || p as isize == 1
                || p as isize == 2
                || p as isize == 3
                || p as isize == -1;
            if !bad {
                return p;
            }
        }
        match GetProcAddress(opengl32, c.as_ptr() as *const u8) {
            Some(p) => p as *const std::ffi::c_void,
            None => std::ptr::null(),
        }
    }
}

/// Resolve a GL entry point on Linux: glXGetProcAddress knows both core and
/// extension entry points, and a plain dlsym covers anything it does not.
#[cfg(unix)]
fn gl_proc(name: &str) -> *const std::ffi::c_void {
    use std::ffi::CString;
    // SAFETY: libGL is loaded (the game is rendering); every lookup is by
    // NUL-terminated name and the result is only used as a function pointer.
    unsafe {
        let Ok(c) = CString::new(name) else {
            return std::ptr::null();
        };
        let mut getproc = libc::dlsym(libc::RTLD_DEFAULT, c"glXGetProcAddressARB".as_ptr());
        if getproc.is_null() {
            getproc = libc::dlsym(libc::RTLD_DEFAULT, c"glXGetProcAddress".as_ptr());
        }
        if !getproc.is_null() {
            let f: unsafe extern "C" fn(*const u8) -> *const std::ffi::c_void =
                std::mem::transmute(getproc);
            let p = f(c.as_ptr() as *const u8);
            if !p.is_null() {
                return p;
            }
        }
        libc::dlsym(libc::RTLD_DEFAULT, c.as_ptr()) as *const std::ffi::c_void
    }
}

/// The slice of GL state the ImGui renderer touches or the game leaves, saved
/// so the game's frame is unharmed.
struct GlState {
    draw_framebuffer: i32,
    program: i32,
    vao: i32,
    array_buffer: i32,
    active_texture: i32,
    texture: i32,
    sampler0: i32,
    unpack_buffer: i32,
    unpack_alignment: i32,
    unpack_row_length: i32,
    unpack_skip_pixels: i32,
    unpack_skip_rows: i32,
    viewport: [i32; 4],
    scissor: [i32; 4],
    blend: bool,
    blend_src_rgb: i32,
    blend_dst_rgb: i32,
    blend_src_alpha: i32,
    blend_dst_alpha: i32,
    blend_eq_rgb: i32,
    blend_eq_alpha: i32,
    depth_test: bool,
    cull_face: bool,
    scissor_test: bool,
    stencil_test: bool,
    framebuffer_srgb: bool,
}

impl GlState {
    unsafe fn save(gl: &glow::Context) -> Self {
        let mut viewport = [0i32; 4];
        gl.get_parameter_i32_slice(glow::VIEWPORT, &mut viewport);
        let mut scissor = [0i32; 4];
        gl.get_parameter_i32_slice(glow::SCISSOR_BOX, &mut scissor);
        Self {
            draw_framebuffer: gl.get_parameter_i32(glow::DRAW_FRAMEBUFFER_BINDING),
            program: gl.get_parameter_i32(glow::CURRENT_PROGRAM),
            vao: gl.get_parameter_i32(glow::VERTEX_ARRAY_BINDING),
            array_buffer: gl.get_parameter_i32(glow::ARRAY_BUFFER_BINDING),
            active_texture: gl.get_parameter_i32(glow::ACTIVE_TEXTURE),
            texture: gl.get_parameter_i32(glow::TEXTURE_BINDING_2D),
            sampler0: gl.get_parameter_i32(glow::SAMPLER_BINDING),
            unpack_buffer: gl.get_parameter_i32(glow::PIXEL_UNPACK_BUFFER_BINDING),
            unpack_alignment: gl.get_parameter_i32(glow::UNPACK_ALIGNMENT),
            unpack_row_length: gl.get_parameter_i32(glow::UNPACK_ROW_LENGTH),
            unpack_skip_pixels: gl.get_parameter_i32(glow::UNPACK_SKIP_PIXELS),
            unpack_skip_rows: gl.get_parameter_i32(glow::UNPACK_SKIP_ROWS),
            viewport,
            scissor,
            blend: gl.is_enabled(glow::BLEND),
            blend_src_rgb: gl.get_parameter_i32(glow::BLEND_SRC_RGB),
            blend_dst_rgb: gl.get_parameter_i32(glow::BLEND_DST_RGB),
            blend_src_alpha: gl.get_parameter_i32(glow::BLEND_SRC_ALPHA),
            blend_dst_alpha: gl.get_parameter_i32(glow::BLEND_DST_ALPHA),
            blend_eq_rgb: gl.get_parameter_i32(glow::BLEND_EQUATION_RGB),
            blend_eq_alpha: gl.get_parameter_i32(glow::BLEND_EQUATION_ALPHA),
            depth_test: gl.is_enabled(glow::DEPTH_TEST),
            cull_face: gl.is_enabled(glow::CULL_FACE),
            scissor_test: gl.is_enabled(glow::SCISSOR_TEST),
            stencil_test: gl.is_enabled(glow::STENCIL_TEST),
            framebuffer_srgb: gl.is_enabled(glow::FRAMEBUFFER_SRGB),
        }
    }

    /// Put the context into the plain state the renderer assumes.
    unsafe fn neutralise(&self, gl: &glow::Context) {
        gl.bind_framebuffer(glow::DRAW_FRAMEBUFFER, None);
        gl.disable(glow::DEPTH_TEST);
        gl.disable(glow::CULL_FACE);
        gl.disable(glow::STENCIL_TEST);
        gl.disable(glow::SCISSOR_TEST);
        // Plain sRGB values are written; a second hardware conversion washes
        // the whole menu out.
        gl.disable(glow::FRAMEBUFFER_SRGB);
        gl.color_mask(true, true, true, true);
        gl.polygon_mode(glow::FRONT_AND_BACK, glow::FILL);
        gl.active_texture(glow::TEXTURE0);
        // A sampler bound on unit 0 overrides the atlas's own parameters and
        // renders the menu flat black.
        gl.bind_sampler(0, None);
        // With a pixel-unpack buffer bound, texture uploads read from it and
        // the font atlas comes out as noise.
        gl.bind_buffer(glow::PIXEL_UNPACK_BUFFER, None);
        gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 4);
        gl.pixel_store_i32(glow::UNPACK_ROW_LENGTH, 0);
        gl.pixel_store_i32(glow::UNPACK_SKIP_PIXELS, 0);
        gl.pixel_store_i32(glow::UNPACK_SKIP_ROWS, 0);
        gl.use_program(None);
        gl.bind_vertex_array(None);
    }

    unsafe fn restore(&self, gl: &glow::Context) {
        gl.bind_framebuffer(glow::DRAW_FRAMEBUFFER, framebuffer(self.draw_framebuffer));
        gl.use_program(program(self.program));
        gl.bind_vertex_array(vertex_array(self.vao));
        gl.bind_buffer(glow::ARRAY_BUFFER, buffer(self.array_buffer));
        gl.bind_buffer(glow::PIXEL_UNPACK_BUFFER, buffer(self.unpack_buffer));
        gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, self.unpack_alignment);
        gl.pixel_store_i32(glow::UNPACK_ROW_LENGTH, self.unpack_row_length);
        gl.pixel_store_i32(glow::UNPACK_SKIP_PIXELS, self.unpack_skip_pixels);
        gl.pixel_store_i32(glow::UNPACK_SKIP_ROWS, self.unpack_skip_rows);
        gl.active_texture(glow::TEXTURE0);
        gl.bind_sampler(0, sampler(self.sampler0));
        gl.bind_texture(glow::TEXTURE_2D, texture(self.texture));
        gl.active_texture(self.active_texture as u32);
        gl.viewport(self.viewport[0], self.viewport[1], self.viewport[2], self.viewport[3]);
        gl.scissor(self.scissor[0], self.scissor[1], self.scissor[2], self.scissor[3]);
        set_enabled(gl, glow::BLEND, self.blend);
        gl.blend_equation_separate(self.blend_eq_rgb as u32, self.blend_eq_alpha as u32);
        gl.blend_func_separate(
            self.blend_src_rgb as u32,
            self.blend_dst_rgb as u32,
            self.blend_src_alpha as u32,
            self.blend_dst_alpha as u32,
        );
        set_enabled(gl, glow::DEPTH_TEST, self.depth_test);
        set_enabled(gl, glow::CULL_FACE, self.cull_face);
        set_enabled(gl, glow::SCISSOR_TEST, self.scissor_test);
        set_enabled(gl, glow::STENCIL_TEST, self.stencil_test);
        set_enabled(gl, glow::FRAMEBUFFER_SRGB, self.framebuffer_srgb);
    }

    fn log_once(&self) {
        crate::log(&format!(
            "gl at swap: fbo={} program={} vao={} sampler0={} unpackBuf={} rowLen={} srgb={} \
             blend={} depth={} cull={} scissor={} stencil={}",
            self.draw_framebuffer,
            self.program,
            self.vao,
            self.sampler0,
            self.unpack_buffer,
            self.unpack_row_length,
            self.framebuffer_srgb,
            self.blend,
            self.depth_test,
            self.cull_face,
            self.scissor_test,
            self.stencil_test
        ));
    }
}

// glow's handles are NonZero wrappers, so zero has to become None.
fn program(v: i32) -> Option<glow::Program> {
    (v != 0).then(|| unsafe { std::mem::transmute::<u32, glow::Program>(v as u32) })
}
fn vertex_array(v: i32) -> Option<glow::VertexArray> {
    (v != 0).then(|| unsafe { std::mem::transmute::<u32, glow::VertexArray>(v as u32) })
}
fn buffer(v: i32) -> Option<glow::Buffer> {
    (v != 0).then(|| unsafe { std::mem::transmute::<u32, glow::Buffer>(v as u32) })
}
fn texture(v: i32) -> Option<glow::Texture> {
    (v != 0).then(|| unsafe { std::mem::transmute::<u32, glow::Texture>(v as u32) })
}
fn sampler(v: i32) -> Option<glow::Sampler> {
    (v != 0).then(|| unsafe { std::mem::transmute::<u32, glow::Sampler>(v as u32) })
}
fn framebuffer(v: i32) -> Option<glow::Framebuffer> {
    (v != 0).then(|| unsafe { std::mem::transmute::<u32, glow::Framebuffer>(v as u32) })
}

unsafe fn set_enabled(gl: &glow::Context, cap: u32, on: bool) {
    if on {
        gl.enable(cap);
    } else {
        gl.disable(cap);
    }
}