Sign in Sign up
kretrod/lodestone Public
Branches
master
1532 lines (1435 loc) · 55.5 KB Raw
//! Drawing the menu inside the game's own OpenGL context.
//!
//! The game is mid-frame when our hook runs: its shader program, sampler
//! objects, blend mode, scissor box and pixel-transfer settings are all set up
//! for whatever it was drawing. Two things follow from that:
//!
//!   * we must neutralise the state egui assumes is default — a sampler object
//!     left bound on unit 0 renders the whole menu flat black, and a bound
//!     pixel-unpack buffer turns every font-atlas upload into garbage;
//!   * we must put all of it back before returning, or the game's next frame
//!     renders wrong.

use std::ffi::CString;
use std::sync::Arc;
use std::time::Instant;

use egui::{Align2, Color32, FontId, Rounding, Stroke};
use glow::HasContext;

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

/// Monocraft — a Minecraft-styled typeface, SIL Open Font License. Loaded from
/// disk rather than embedded: a ~4 MB image refuses to load into the game, and
/// this way the font can be swapped without a rebuild.
const FONT_PATH: &str = "C:\\lodestone\\Monocraft.ttf";

const BG: Color32 = Color32::from_rgb(20, 21, 26);
const LINE: Color32 = Color32::from_rgb(48, 52, 62);
const ACCENT: Color32 = Color32::from_rgb(118, 220, 140);
const TEXT: Color32 = Color32::from_rgb(233, 236, 242);
const DIM: Color32 = Color32::from_rgb(138, 145, 158);
const WARN: Color32 = Color32::from_rgb(232, 186, 104);
const OFF: Color32 = Color32::from_rgb(58, 62, 72);

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

impl Tab {
    const ALL: [Tab; 5] = [Tab::Combat, Tab::Movement, Tab::Esp, Tab::Visuals, Tab::Misc];
    fn label(self) -> &'static str {
        match self {
            Tab::Combat => "COMBAT",
            Tab::Movement => "MOVEMENT",
            Tab::Esp => "ESP",
            Tab::Visuals => "VISUALS",
            Tab::Misc => "MISC",
        }
    }
    /// Long sections get more room before they start scrolling.
    fn height(self) -> f32 {
        match self {
            Tab::Combat | Tab::Movement => 330.0,
            Tab::Esp => 300.0,
            Tab::Visuals => 250.0,
            Tab::Misc => 210.0,
        }
    }
}

pub struct Overlay {
    ctx: egui::Context,
    painter: egui_glow::Painter,
    gl: Arc<glow::Context>,
    start: Instant,
    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)
        };
        let gl = Arc::new(gl);
        // SAFETY: as above.
        unsafe {
            crate::log(&format!(
                "gl version {:?} renderer {:?}",
                gl.get_parameter_string(glow::VERSION),
                gl.get_parameter_string(glow::RENDERER)
            ));
        }
        let painter = egui_glow::Painter::new(gl.clone(), "", None, false)
            .map_err(|e| format!("egui painter: {e}"))?;

        let ctx = egui::Context::default();
        ctx.set_fonts(fonts());
        ctx.set_style(style());
        Ok(Self {
            ctx,
            painter,
            gl,
            start: Instant::now(),
            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();
        self.last_frame = Instant::now();
        self.frame_times[self.frame_i] = dt;
        self.frame_i = (self.frame_i + 1) % self.frame_times.len();

        let scale = shared.cfg.ui_scale.clamp(0.5, 3.0);
        shared.scale = scale;
        let size = egui::vec2(width as f32 / scale, height as f32 / scale);

        let raw = egui::RawInput {
            screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)),
            time: Some(self.start.elapsed().as_secs_f64()),
            events: std::mem::take(&mut shared.events),
            focused: true,
            ..Default::default()
        };

        let open = shared.menu_open;
        // egui::Context is a handle; cloning it frees `self` for the closure.
        let ctx = self.ctx.clone();
        let out = ctx.run(raw, |ctx| {
            if open {
                self.menu(ctx, shared);
            }
            self.esp_overlay(ctx, shared);
            self.hud(ctx, shared, open);
        });

        let prims = ctx.tessellate(out.shapes, out.pixels_per_point);
        // SAFETY: we are on the render thread with the game's context current.
        unsafe {
            let saved = GlState::save(&self.gl);
            if !self.logged_state {
                self.logged_state = true;
                saved.log_once();
            }
            saved.neutralise(&self.gl);
            self.painter.paint_and_update_textures(
                [width as u32, height as u32],
                scale,
                &prims,
                &out.textures_delta,
            );
            saved.restore(&self.gl);
        }
    }

    // ---- the menu ----------------------------------------------------------
    //
    // Five independent windows rather than one panel: each section is its own
    // thing, dragged and collapsed on its own, so you can keep Combat and ESP
    // open side by side and shove the rest out of the way. egui remembers
    // where you put each one and whether it is folded up.

    fn menu(&mut self, ctx: &egui::Context, shared: &mut Shared) {
        let screen = ctx.screen_rect();
        let width = 236.0;
        let gap = 10.0;
        // How many fit across before wrapping to a second row.
        let per_row = (((screen.width() - 60.0) / (width + gap)).floor() as usize).clamp(1, 5);

        for (i, tab) in Tab::ALL.into_iter().enumerate() {
            let col = i % per_row;
            let row = i / per_row;
            let pos = [
                30.0 + col as f32 * (width + gap),
                44.0 + row as f32 * 356.0,
            ];
            let title = egui::RichText::new(tab.label()).size(12.0).color(ACCENT);
            egui::Window::new(title)
                .id(egui::Id::new(("lodestone-section", i)))
                .default_pos(pos)
                .default_width(width)
                .resizable(false)
                .collapsible(true)
                .frame(
                    egui::Frame::none()
                        .fill(BG)
                        .stroke(Stroke::new(1.0, LINE))
                        .rounding(Rounding::ZERO)
                        .inner_margin(egui::Margin::symmetric(8.0, 6.0)),
                )
                .show(ctx, |ui| {
                    ui.set_width(width - 16.0);
                    egui::ScrollArea::vertical()
                        .id_salt(("lodestone-scroll", i))
                        .max_height(tab.height())
                        .auto_shrink([false, true])
                        .show(ui, |ui| match tab {
                            Tab::Combat => combat(ui, shared),
                            Tab::Movement => movement(ui, shared),
                            Tab::Esp => esp(ui, shared),
                            Tab::Visuals => visuals(ui, shared),
                            Tab::Misc => misc(ui, shared),
                        });
                });
        }
    }

    // ---- world overlay -----------------------------------------------------

    /// Draw the scanned entities into the world, projecting each one with the
    /// same camera the game is rendering from.
    fn esp_overlay(&self, ctx: &egui::Context, shared: &Shared) {
        let cfg = &shared.cfg.esp;
        let g = &shared.game;
        let nothing_to_draw =
            g.targets.is_empty() && g.blocks.is_empty() && g.base_hits.is_empty();
        if nothing_to_draw {
            return;
        }
        let layer = egui::LayerId::new(egui::Order::Background, egui::Id::new("lodestone-esp"));
        let painter = ctx.layer_painter(layer);
        let screen = ctx.screen_rect();
        let view = View::new(g.camera, g.camera_yaw, g.camera_pitch, g.fov, screen.size());

        // Blocks first, so entities draw over them. Capped: selecting coal at a
        // wide radius can find tens of thousands, and every one is eight
        // projected corners and a rectangle.
        const MAX_BLOCK_BOXES: usize = 3000;
        if !g.blocks.is_empty() {
            for (x, y, z, kind) in g.blocks.iter().take(MAX_BLOCK_BOXES) {
                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);
                let Some(rect) = view.project_box(min, max) else {
                    continue;
                };
                if !screen.intersects(rect) {
                    continue;
                }
                let crate::state::BlockKind::Ore(group) = kind;
                painter.rect_stroke(rect, Rounding::ZERO, Stroke::new(1.0, ore_colour(*group)));
            }
        }

        // Block entities: containers, and the things that mark out a base.
        for hit in &g.base_hits {
            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);
            let Some(rect) = view.project_box(min, max) else {
                continue;
            };
            if !screen.intersects(rect) {
                continue;
            }
            let colour = if hit.is_base {
                Color32::from_rgb(236, 130, 220)
            } else {
                Color32::from_rgb(232, 186, 104)
            };
            painter.rect_stroke(rect, Rounding::ZERO, Stroke::new(1.0, colour));
            if hit.is_base || rect.height() > 18.0 {
                painter.text(
                    rect.center_top() + egui::vec2(0.0, -11.0),
                    Align2::CENTER_TOP,
                    format!("{} [{:.0}m]", hit.label, hit.distance),
                    FontId::proportional(9.5),
                    colour,
                );
            }
        }

        for t in &g.targets {
            let Some(rect) = view.project_box(t.min, t.max) else {
                continue;
            };
            if !screen.intersects(rect) {
                continue;
            }
            let colour = kind_colour(t.kind);

            if cfg.boxes {
                painter.rect_stroke(rect, Rounding::ZERO, Stroke::new(1.0, colour));
                // A darker outline keeps the box readable against bright terrain.
                painter.rect_stroke(
                    rect.expand(1.0),
                    Rounding::ZERO,
                    Stroke::new(1.0, Color32::from_black_alpha(120)),
                );
            }
            if cfg.tracers {
                painter.line_segment(
                    [egui::pos2(screen.center().x, screen.bottom()), rect.center_bottom()],
                    Stroke::new(1.0, colour.linear_multiply(0.8)),
                );
            }
            if cfg.health_bars && t.max_health > 0.0 {
                let frac = (t.health / t.max_health).clamp(0.0, 1.0);
                let bar = egui::Rect::from_min_max(
                    egui::pos2(rect.left() - 5.0, rect.top()),
                    egui::pos2(rect.left() - 2.0, rect.bottom()),
                );
                painter.rect_filled(bar, Rounding::ZERO, Color32::from_black_alpha(140));
                let filled = egui::Rect::from_min_max(
                    egui::pos2(bar.left(), bar.bottom() - bar.height() * frac),
                    bar.max,
                );
                painter.rect_filled(
                    filled,
                    Rounding::ZERO,
                    Color32::from_rgb(
                        (255.0 * (1.0 - frac)) as u8,
                        (220.0 * frac) as u8,
                        80,
                    ),
                );
            }
            if cfg.nametags && !t.name.is_empty() {
                let label = format!("{} [{:.0}m]", t.name, t.distance);
                let pos = egui::pos2(rect.center().x, rect.top() - 12.0);
                painter.text(
                    pos + egui::vec2(1.0, 1.0),
                    Align2::CENTER_TOP,
                    &label,
                    FontId::proportional(10.0),
                    Color32::from_black_alpha(160),
                );
                painter.text(pos, Align2::CENTER_TOP, &label, FontId::proportional(10.0), colour);
            }
        }
    }

    // ---- always-on HUD -----------------------------------------------------

    fn hud(&self, ctx: &egui::Context, shared: &Shared, menu_open: bool) {
        let cfg = &shared.cfg;
        let g = &shared.game;
        let layer = egui::LayerId::new(egui::Order::Background, egui::Id::new("lodestone-hud"));
        let painter = ctx.layer_painter(layer);
        let screen = ctx.screen_rect();

        if cfg.visuals.watermark {
            painter.text(
                egui::pos2(10.0, 7.0),
                Align2::LEFT_TOP,
                "LODESTONE",
                FontId::proportional(16.0),
                ACCENT,
            );
            if !menu_open {
                painter.text(
                    egui::pos2(10.0, 26.0),
                    Align2::LEFT_TOP,
                    "[insert]",
                    FontId::proportional(10.0),
                    DIM,
                );
            }
        }

        if cfg.visuals.hud_coords && g.in_world {
            painter.text(
                egui::pos2(10.0, screen.height() - 20.0),
                Align2::LEFT_TOP,
                format!("{:.1} {:.1} {:.1}", g.pos.0, g.pos.1, g.pos.2),
                FontId::proportional(12.0),
                TEXT,
            );
        }

        if cfg.visuals.hud_modules {
            let mut y = 7.0;
            for name in active_modules(cfg) {
                painter.text(
                    egui::pos2(screen.width() - 10.0, y),
                    Align2::RIGHT_TOP,
                    name,
                    FontId::proportional(12.0),
                    ACCENT,
                );
                y += 15.0;
            }
        }
    }
}

/// One colour per X-Ray group, in the order they are listed.
fn ore_colour(group: usize) -> Color32 {
    const PALETTE: &[Color32] = &[
        Color32::from_rgb(108, 222, 226), // diamond
        Color32::from_rgb(176, 120, 220), // ancient debris
        Color32::from_rgb(104, 222, 132), // emerald
        Color32::from_rgb(240, 208, 96),  // gold
        Color32::from_rgb(214, 178, 150), // iron
        Color32::from_rgb(238, 90, 90),   // redstone
        Color32::from_rgb(96, 132, 232),  // lapis
        Color32::from_rgb(226, 142, 88),  // copper
        Color32::from_rgb(130, 136, 146), // coal
        Color32::from_rgb(228, 224, 214), // quartz
        Color32::from_rgb(196, 108, 232), // spawner
        Color32::from_rgb(236, 108, 196), // portal
        Color32::from_rgb(232, 186, 104), // chest
    ];
    PALETTE.get(group).copied().unwrap_or(ACCENT)
}

fn kind_colour(kind: crate::mc::TargetKind) -> Color32 {
    use crate::mc::TargetKind::*;
    match kind {
        Player => Color32::from_rgb(240, 96, 110),
        Mob => Color32::from_rgb(236, 160, 84),
        Animal => Color32::from_rgb(126, 216, 140),
        Item => Color32::from_rgb(120, 190, 236),
        Other => DIM,
    }
}

/// World-to-screen for one frame's camera.
///
/// Minecraft's yaw is zero looking south (+Z) and increases clockwise, so the
/// forward vector is (-sin yaw · cos pitch, -sin pitch, cos yaw · cos pitch).
/// Right is forward × world-up, and the projection is an ordinary pinhole with
/// the game's vertical field of view.
struct View {
    camera: (f64, f64, f64),
    right: (f64, f64, f64),
    up: (f64, f64, f64),
    forward: (f64, f64, f64),
    half: egui::Vec2,
    tan_half_fov: f64,
    aspect: f64,
}

impl View {
    fn new(
        camera: (f64, f64, f64),
        yaw: f32,
        pitch: f32,
        fov: f32,
        size: egui::Vec2,
    ) -> 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);
        // forward × (0,1,0)
        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);
        // up = right × forward
        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 / 2.0,
            tan_half_fov: ((fov.max(1.0) as f64) / 2.0).to_radians().tan(),
            aspect: (size.x / size.y.max(1.0)) as f64,
        }
    }

    fn project(&self, p: (f64, f64, f64)) -> Option<egui::Pos2> {
        let d = (
            p.0 - self.camera.0,
            p.1 - self.camera.1,
            p.2 - self.camera.2,
        );
        let z = dot(d, self.forward);
        // Anything at or behind the eye has no meaningful screen position.
        if z <= 0.05 {
            return None;
        }
        let x = dot(d, self.right);
        let y = dot(d, self.up);
        Some(egui::pos2(
            self.half.x * (1.0 + (x / (z * self.tan_half_fov * self.aspect)) as f32),
            self.half.y * (1.0 - (y / (z * self.tan_half_fov)) as f32),
        ))
    }

    /// The screen rectangle covering all eight corners of a world-space box.
    fn project_box(&self, min: (f64, f64, f64), max: (f64, f64, f64)) -> Option<egui::Rect> {
        let mut rect: Option<egui::Rect> = None;
        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)?;
            rect = Some(match rect {
                None => egui::Rect::from_min_max(p, p),
                Some(r) => r.union(egui::Rect::from_min_max(p, p)),
            });
        }
        rect
    }
}

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

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"),
        (e.base_finder, "Base Finder"),
        (cb.criticals, "Criticals"),
        (cb.auto_dodge, "Auto Dodge"),
        (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"),
        (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
}

// ---- chrome ----------------------------------------------------------------





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

/// A square, pixel-styled tick box — drawn rather than using egui's own, so it
/// matches the blocky typeface instead of fighting it.
fn tick_box(painter: &egui::Painter, centre: egui::Pos2, size: f32, on: bool, hot: bool) {
    let outer = egui::Rect::from_center_size(centre, egui::vec2(size, size));
    painter.rect_filled(outer, Rounding::ZERO, if on { ACCENT } else { OFF });
    let border = if hot { ACCENT } else { LINE };
    painter.rect_stroke(outer, Rounding::ZERO, Stroke::new(1.0, border));
    if on {
        let inner = egui::Rect::from_center_size(centre, egui::vec2(size - 6.0, size - 6.0));
        painter.rect_filled(inner, Rounding::ZERO, BG);
    }
}

/// A module row: name on the left, tick box on the right, whole row clickable.
/// A module row: name, its keybind, and a tick box. The row toggles; the bind
/// chip starts listening for the next key.
fn module(ui: &mut egui::Ui, shared: &mut Shared, id: ModuleId) -> bool {
    let on = id.get(&shared.cfg);
    let target = BindTarget::Module(id);
    let listening = shared.binding == Some(target);
    let bind = shared.bind_for(target);

    let height = 24.0;
    let width = ui.available_width();
    let (rect, response) =
        ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::click());

    // The bind chip sits inside the row, left of the tick box.
    let chip = egui::Rect::from_min_size(
        egui::pos2(rect.right() - 76.0, rect.center().y - 8.0),
        egui::vec2(46.0, 16.0),
    );
    let chip_id = ui.id().with((id.key(), "bind"));
    let chip_response = ui.interact(chip, chip_id, egui::Sense::click());

    if chip_response.clicked() {
        shared.binding = Some(target);
    } else if response.clicked() {
        id.toggle(&mut shared.cfg);
    }

    let painter = ui.painter();
    if response.hovered() {
        painter.rect_filled(rect, Rounding::ZERO, Color32::from_rgb(36, 39, 47));
    }
    painter.text(
        egui::pos2(rect.left() + 8.0, rect.center().y),
        Align2::LEFT_CENTER,
        id.label(),
        FontId::proportional(12.0),
        if on { TEXT } else { DIM },
    );

    let label = if listening {
        "...".to_string()
    } else {
        bind.map(key_name).unwrap_or_default()
    };
    if listening || !label.is_empty() || chip_response.hovered() {
        painter.rect_filled(
            chip,
            Rounding::ZERO,
            if listening { ACCENT } else { Color32::from_rgb(38, 41, 49) },
        );
        painter.text(
            chip.center(),
            Align2::CENTER_CENTER,
            if label.is_empty() { "bind" } else { label.as_str() },
            FontId::proportional(9.5),
            if listening { BG } else { DIM },
        );
    }

    tick_box(
        painter,
        egui::pos2(rect.right() - 14.0, rect.center().y),
        14.0,
        on,
        response.hovered(),
    );
    response.clicked()
}

/// 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}"),
    }
}

fn slider(ui: &mut egui::Ui, v: &mut f32, range: std::ops::RangeInclusive<f32>, label: &str) {
    ui.horizontal(|ui| {
        ui.add_space(10.0);
        ui.add(
            egui::Slider::new(v, range)
                .text(egui::RichText::new(label).size(10.0).color(DIM))
                .fixed_decimals(2),
        );
    });
    ui.add_space(2.0);
}

/// A segmented mode selector.
fn modes<T: Copy + PartialEq>(
    ui: &mut egui::Ui,
    value: &mut T,
    all: impl IntoIterator<Item = T>,
    label: impl Fn(T) -> &'static str,
) {
    ui.horizontal_wrapped(|ui| {
        ui.add_space(10.0);
        for m in all {
            let selected = *value == m;
            if ui
                .add(
                    egui::Button::new(
                        egui::RichText::new(label(m))
                            .size(10.0)
                            .color(if selected { BG } else { DIM }),
                    )
                    .fill(if selected { ACCENT } else { OFF })
                    .rounding(Rounding::ZERO),
                )
                .clicked()
            {
                *value = m;
            }
        }
    });
    ui.add_space(3.0);
}

fn note(ui: &mut egui::Ui, text: &str) {
    ui.horizontal_wrapped(|ui| {
        ui.add_space(10.0);
        ui.label(egui::RichText::new(text).size(9.5).color(DIM));
    });
    ui.add_space(4.0);
}

/// The same tick box at small size, with its label to the right.
fn check(ui: &mut egui::Ui, v: &mut bool, label: &str) {
    let text = egui::RichText::new(label).size(10.5);
    let galley = ui.painter().layout_no_wrap(
        label.to_owned(),
        FontId::proportional(10.5),
        if *v { TEXT } else { DIM },
    );
    let w = galley.size().x + 20.0;
    let (rect, response) = ui.allocate_exact_size(egui::vec2(w, 18.0), egui::Sense::click());
    if response.clicked() {
        *v = !*v;
    }
    let painter = ui.painter();
    tick_box(
        painter,
        egui::pos2(rect.left() + 6.0, rect.center().y),
        11.0,
        *v,
        response.hovered(),
    );
    painter.galley(
        egui::pos2(rect.left() + 16.0, rect.center().y - galley.size().y / 2.0),
        galley,
        TEXT,
    );
    let _ = text;
}

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

fn combat(ui: &mut egui::Ui, shared: &mut Shared) {
    module(ui, shared, ModuleId::KillAura);
    if shared.cfg.combat.kill_aura {
        let c = &mut shared.cfg.combat;
        modes(ui, &mut c.aura_target, AuraTarget::ALL, |m| m.label());
        slider(ui, &mut c.aura_range, 2.0..=6.0, "range");
        ui.horizontal(|ui| {
            ui.add_space(10.0);
            check(ui, &mut c.aura_players, "players");
            check(ui, &mut c.aura_mobs, "mobs");
            check(ui, &mut c.aura_animals, "animals");
        });
        ui.horizontal(|ui| {
            ui.add_space(10.0);
            check(ui, &mut c.aura_through_walls, "through walls");
            check(ui, &mut c.aura_rotate, "rotate");
        });
        ui.horizontal(|ui| {
            ui.add_space(10.0);
            check(ui, &mut c.aura_cooldown, "wait for cooldown");
        });
        if c.aura_cooldown {
            note(
                ui,
                "swings only on a full attack bar. Damage scales with the \
                 cooldown, so this hits harder than spamming and looks like \
                 someone who can time a click.",
            );
        } else {
            slider(ui, &mut c.aura_cps, 1.0..=20.0, "swings/s");
            note(ui, "free-swinging lands far less damage per hit");
        }
        ui.add_space(4.0);
    }

    module(ui, shared, ModuleId::Aimbot);
    if shared.cfg.combat.aimbot {
        let c = &mut shared.cfg.combat;
        modes(ui, &mut c.aim_mode, AimMode::ALL, |m| m.label());
        let hint = match c.aim_mode {
            AimMode::Camera => "turns your view; anyone watching you sees it snap",
            AimMode::Silent => "aims only for the swing, your view stays put",
        };
        slider(ui, &mut c.aim_fov, 5.0..=180.0, "fov");
        slider(ui, &mut c.aim_speed, 0.05..=1.0, "smoothing");
        note(ui, hint);
        note(ui, "lower smoothing turns gradually instead of snapping");
    }

    module(ui, shared, ModuleId::TriggerBot);
    if shared.cfg.combat.trigger_bot {
        note(ui, "swings only when your crosshair is already on a target");
        slider(ui, &mut shared.cfg.combat.trigger_delay, 0.0..=0.5, "delay");
    }

    module(ui, shared, ModuleId::Reach);
    if shared.cfg.combat.reach {
        slider(ui, &mut shared.cfg.combat.reach_distance, 3.0..=6.0, "distance");
        note(ui, "the server checks this; past its limit the hit is dropped");
    }

    module(ui, shared, ModuleId::AutoClicker);
    if shared.cfg.combat.auto_clicker {
        let c = &mut shared.cfg.combat;
        slider(ui, &mut c.click_cps, 1.0..=20.0, "cps");
        slider(ui, &mut c.click_jitter, 0.0..=1.0, "jitter");
        note(ui, "jitter breaks up the rhythm so it is not machine-even");
    }

    module(ui, shared, ModuleId::Criticals);
    if shared.cfg.combat.criticals {
        note(ui, "hops before each swing so every hit counts as a critical");
    }

    module(ui, shared, ModuleId::AutoDodge);
    if shared.cfg.combat.auto_dodge {
        let c = &mut shared.cfg.combat;
        slider(ui, &mut c.dodge_range, 3.0..=16.0, "threat range");
        slider(ui, &mut c.dodge_speed, 0.1..=0.5, "speed");
        ui.horizontal(|ui| {
            ui.add_space(10.0);
            check(ui, &mut c.dodge_arrows, "arrows");
            check(ui, &mut c.dodge_cliffs, "avoid ledges");
        });
        note(
            ui,
            "steps out of the path of arrows already in flight, and keeps its \
             distance from hostiles — picking the nearest direction it can \
             actually walk in rather than running face-first into a wall. It \
             hops one-block steps instead of stalling on them.",
        );
        ui.add_space(4.0);
    }

    module(ui, shared, ModuleId::AntiKnockback);
    if shared.cfg.combat.anti_knockback {
        let c = &mut shared.cfg.combat;
        slider(ui, &mut c.kb_horizontal, 0.0..=1.0, "horizontal");
        slider(ui, &mut c.kb_vertical, 0.0..=1.0, "vertical");
        note(ui, "0 removes knockback entirely, which stands out; leaving some does not");
    }
}

fn movement(ui: &mut egui::Ui, shared: &mut Shared) {
    module(ui, shared, ModuleId::Fly);
    if shared.cfg.movement.fly {
        let c = &mut shared.cfg.movement;
        modes(ui, &mut c.fly_mode, FlyMode::ALL, |m| m.label());
        let hint = c.fly_mode.note();
        match c.fly_mode {
            FlyMode::Teleport => {
                slider(ui, &mut c.fly_step, 0.5..=8.0, "step");
                slider(ui, &mut c.fly_step_interval, 0.05..=1.0, "interval");
            }
            FlyMode::Creative => slider(ui, &mut c.fly_speed, 0.01..=0.6, "speed"),
            _ => slider(ui, &mut c.fly_speed, 0.05..=2.0, "speed"),
        }
        note(ui, hint);
    }

    if shared.cfg.movement.fly {
        let c = &mut shared.cfg.movement;
        ui.horizontal(|ui| {
            ui.add_space(10.0);
            check(ui, &mut c.fly_anti_kick, "anti-kick dip");
        });
        if c.fly_anti_kick {
            slider(ui, &mut c.fly_dip_interval, 0.5..=4.0, "dip every (s)");
            note(
                ui,
                "a server counts ticks where you fail to fall and disconnects \
                 you after about four seconds of it. Dropping briefly resets \
                 that counter, so the kick never comes.",
            );
        }
    }

    module(ui, shared, ModuleId::Bhop);
    if shared.cfg.movement.bhop {
        note(ui, "sprint-jumps for you — vanilla's own speed bonus, nothing to correct");
    }

    module(ui, shared, ModuleId::Blink);
    if shared.cfg.movement.blink {
        note(
            ui,
            "holds your position packets back: you move, the server still sees \
             you where you stopped. Switching it off sends the truth, which a \
             server may well refuse.",
        );
    }

    module(ui, shared, ModuleId::Speed);
    if shared.cfg.movement.speed {
        let c = &mut shared.cfg.movement;
        modes(ui, &mut c.speed_mode, SpeedMode::ALL, |m| m.label());
        match c.speed_mode {
            SpeedMode::Abilities => {
                slider(ui, &mut c.speed_value, 0.05..=1.0, "walk speed");
                note(ui, "raises the walking-speed ability; smooth and steady");
            }
            SpeedMode::Velocity => {
                slider(ui, &mut c.speed_value, 0.1..=1.0, "blocks/tick");
                note(ui, "sets your speed outright once a tick — normal walking is 0.13");
            }
        }
    }

    module(ui, shared, ModuleId::NoFall);
    module(ui, shared, ModuleId::Jetpack);
    if shared.cfg.movement.jetpack {
        note(ui, "hold jump to climb");
        slider(ui, &mut shared.cfg.movement.jetpack_power, 0.1..=1.5, "power");
    }
    module(ui, shared, ModuleId::Sprint);
    module(ui, shared, ModuleId::Noclip);
    if shared.cfg.movement.noclip {
        note(ui, "collision off — a server will pull you back out of walls");
    }
    module(ui, shared, ModuleId::Step);
    if shared.cfg.movement.step {
        slider(ui, &mut shared.cfg.movement.step_height, 0.6..=3.0, "height");
    }
    module(ui, shared, ModuleId::HighJump);
    if shared.cfg.movement.jump_power {
        slider(ui, &mut shared.cfg.movement.jump_multiplier, 1.0..=4.0, "multiplier");
    }
    module(ui, shared, ModuleId::Jesus);
    module(ui, shared, ModuleId::Spider);
    if shared.cfg.movement.spider {
        slider(ui, &mut shared.cfg.movement.spider_power, 0.1..=0.6, "grip");
    }
    module(ui, shared, ModuleId::Freecam);
    if shared.cfg.movement.freecam {
        slider(ui, &mut shared.cfg.movement.freecam_speed, 0.1..=3.0, "speed");
        note(
            ui,
            "spectator, client-side. The HUD hides, the camera detaches onto an \
             entity that \
             is never added to the world, and your body stays put doing exactly \
             what the server expects — so there is nothing to correct. WASD, \
             space and shift fly the camera; the mouse turns it instead of you.",
        );
    }

    ui.add_space(8.0);
    ui.label(egui::RichText::new("SERVER").size(9.5).color(DIM));
    ui.add_space(4.0);
    ui.horizontal(|ui| {
        ui.add_space(10.0);
        check(ui, &mut shared.cfg.movement.speed_limit, "stay within server limits");
    });
    note(
        ui,
        "caps speed and teleport steps to what a server accepts without pulling \
         you back. Turn it off in single player.",
    );
}

fn esp(ui: &mut egui::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::Xray);
    if shared.cfg.esp.xray {
        // Pick exactly what you want to see through stone.
        let selected = &mut shared.cfg.esp.xray_selected;
        selected.resize(crate::blocks::ORE_GROUPS.len(), false);
        egui::Frame::none()
            .fill(Color32::from_rgb(24, 26, 32))
            .inner_margin(egui::Margin::symmetric(8.0, 6.0))
            .show(ui, |ui| {
                let mut column = 0;
                ui.horizontal_wrapped(|ui| {
                    for (i, (label, _)) in crate::blocks::ORE_GROUPS.iter().enumerate() {
                        let mut on = selected[i];
                        let response = ui.allocate_response(
                            egui::vec2(96.0, 18.0),
                            egui::Sense::click(),
                        );
                        if response.clicked() {
                            on = !on;
                            selected[i] = on;
                        }
                        let painter = ui.painter();
                        tick_box(
                            painter,
                            egui::pos2(response.rect.left() + 6.0, response.rect.center().y),
                            11.0,
                            on,
                            response.hovered(),
                        );
                        painter.text(
                            egui::pos2(response.rect.left() + 16.0, response.rect.center().y),
                            Align2::LEFT_CENTER,
                            *label,
                            FontId::proportional(10.0),
                            if on { ore_colour(i) } else { DIM },
                        );
                        column += 1;
                        if column % 2 == 0 {
                            ui.end_row();
                        }
                    }
                });
            });
        slider(ui, &mut shared.cfg.esp.block_radius, 8.0..=48.0, "block range");
        note(
            ui,
            "ores have no list to walk, so the volume is swept a slice per frame \
             and swapped in when a pass finishes — a wider range just takes \
             longer to fill in.",
        );
    }

    module(ui, shared, ModuleId::BaseFinder);
    if shared.cfg.esp.base_finder {
        slider(ui, &mut shared.cfg.esp.base_chunk_radius, 2.0..=16.0, "chunks");
        note(
            ui,
            "shulker boxes, ender chests, beacons, brewing stands and enchanting \
             tables — block entities the client already tracks, so this reads \
             your whole loaded area at once rather than sweeping it.",
        );
        let hits: Vec<_> = shared
            .game
            .base_hits
            .iter()
            .filter(|h| h.is_base)
            .take(10)
            .cloned()
            .collect();
        if hits.is_empty() {
            ui.label(egui::RichText::new("nothing found nearby").size(10.0).color(DIM));
        } else {
            for h in hits {
                ui.label(
                    egui::RichText::new(format!(
                        "{:<14} {:>6} {:>4} {:>6}  {:.0}m",
                        h.label, h.x, h.y, h.z, h.distance
                    ))
                    .size(9.5)
                    .color(Color32::from_rgb(236, 130, 220)),
                );
            }
        }
        ui.add_space(4.0);
    }
    ui.add_space(8.0);
    ui.label(egui::RichText::new("DRAWING").size(9.5).color(DIM));
    ui.add_space(4.0);
    module(ui, shared, ModuleId::EspBoxes);
    module(ui, shared, ModuleId::EspTracers);
    module(ui, shared, ModuleId::EspNametags);
    module(ui, shared, ModuleId::EspHealth);
    slider(ui, &mut shared.cfg.esp.distance, 16.0..=256.0, "range");
}

fn visuals(ui: &mut egui::Ui, shared: &mut Shared) {
    module(ui, shared, ModuleId::Fullbright);
    module(ui, shared, ModuleId::NoFog);
    if shared.cfg.visuals.no_fog {
        note(ui, "not wired yet — fog moved into the environment system in 26.2");
    }
    module(ui, shared, ModuleId::NoHurtCam);
    module(ui, shared, ModuleId::NoWeather);
    module(ui, shared, ModuleId::NoBob);
    module(ui, shared, ModuleId::ViewDistance);
    if shared.cfg.visuals.view_distance {
        slider(ui, &mut shared.cfg.visuals.view_distance_chunks, 8.0..=32.0, "chunks");
        note(
            ui,
            "a server sends chunks out to the smaller of its own view distance \
             and the one your client asks for, so asking for more is the real \
             way to get more — up to the server's own limit and no further. \
             Capped at 32: past vanilla's own maximum the renderer is outside \
             the range its buffers were sized for.",
        );
        ui.label(
            egui::RichText::new(format!("{} chunks loaded", shared.game.loaded_chunks))
                .size(10.0)
                .color(DIM),
        );
    }

    module(ui, shared, ModuleId::NoCulling);
    if shared.cfg.visuals.no_culling {
        note(
            ui,
            "stops the renderer skipping chunk sections it thinks are hidden, so \
             a cave draws instead of showing you a black wall. Pair it with \
             Fullbright. It cannot show chunks the server never sent — only the \
             ones you already have and were not being drawn.",
        );
    }
    module(ui, shared, ModuleId::CustomFov);
    if shared.cfg.visuals.fov {
        slider(ui, &mut shared.cfg.visuals.fov_value, 30.0..=140.0, "fov");
    }
    ui.add_space(8.0);
    ui.label(egui::RichText::new("HUD").size(9.5).color(DIM));
    ui.add_space(4.0);
    module(ui, shared, ModuleId::Watermark);
    module(ui, shared, ModuleId::HudCoords);
    module(ui, shared, ModuleId::HudModules);
}

fn misc(ui: &mut egui::Ui, shared: &mut Shared) {
    let g = shared.game.clone();
    let (world, col) = if !g.in_world {
        ("no world", DIM)
    } else if g.single_player {
        ("single player", ACCENT)
    } else {
        ("server", WARN)
    };
    ui.label(egui::RichText::new(world).size(10.5).color(col));
    ui.label(
        egui::RichText::new(format!("{:.1} {:.1} {:.1}", g.pos.0, g.pos.1, g.pos.2))
            .size(10.0)
            .color(DIM),
    );
    ui.label(
        egui::RichText::new(format!("{:.0} fps · {:.0} hp · {} drawn", g.fps, g.health, g.targets.len()))
            .size(10.0)
            .color(DIM),
    );
    ui.add_space(8.0);

    module(ui, shared, ModuleId::AutoRespawn);
    note(ui, "answers the death screen for you");
    ui.add_space(4.0);

    let changed = module(ui, shared, ModuleId::HideCapture);
    note(
        ui,
        "takes the game window out of recordings and screen shares. The menu is \
         drawn into the game's own frame, so this hides the whole window, not \
         just the menu.",
    );
    if changed {
        crate::capture::apply(shared.cfg.misc.hide_from_capture);
    }

    ui.add_space(6.0);
    // Panic: one key that puts everything back.
    let listening = shared.binding == Some(BindTarget::Panic);
    let bind = shared.bind_for(BindTarget::Panic);
    ui.horizontal(|ui| {
        if ui
            .add(
                egui::Button::new(egui::RichText::new("PANIC").size(10.5).color(WARN))
                    .fill(OFF)
                    .rounding(Rounding::ZERO)
                    .min_size(egui::vec2(70.0, 20.0)),
            )
            .on_hover_text("turn every module off")
            .clicked()
        {
            shared.panic_off();
        }
        let label = if listening {
            "...".to_string()
        } else {
            bind.map(key_name).unwrap_or_else(|| "bind".into())
        };
        if ui
            .add(
                egui::Button::new(egui::RichText::new(label).size(9.5).color(DIM))
                    .fill(if listening { ACCENT } else { OFF })
                    .rounding(Rounding::ZERO)
                    .min_size(egui::vec2(48.0, 20.0)),
            )
            .clicked()
        {
            shared.binding = Some(BindTarget::Panic);
        }
    });
    note(ui, "click a bind chip, then press a key. Escape clears it.");

    ui.add_space(6.0);
    slider(ui, &mut shared.cfg.ui_scale, 0.7..=2.0, "menu scale");

    ui.add_space(8.0);
    ui.horizontal(|ui| {
        if ui
            .add(
                egui::Button::new(egui::RichText::new("SAVE").size(10.5).color(TEXT))
                    .fill(OFF)
                    .rounding(Rounding::ZERO)
                    .min_size(egui::vec2(70.0, 20.0)),
            )
            .clicked()
        {
            shared.status = match crate::config::save(shared) {
                Ok(p) => format!("saved to {p}"),
                Err(e) => format!("save failed: {e}"),
            };
        }
        if ui
            .add(
                egui::Button::new(egui::RichText::new("LOAD").size(10.5).color(TEXT))
                    .fill(OFF)
                    .rounding(Rounding::ZERO)
                    .min_size(egui::vec2(70.0, 20.0)),
            )
            .clicked()
        {
            shared.status = match crate::config::load(shared) {
                Ok(()) => "config loaded".into(),
                Err(e) => format!("load failed: {e}"),
            };
        }
    });

    ui.add_space(10.0);
    if ui
        .add(
            egui::Button::new(egui::RichText::new("UNLOAD").size(11.0).color(WARN))
                .fill(OFF)
                .rounding(Rounding::ZERO)
                .min_size(egui::vec2(ui.available_width(), 24.0)),
        )
        .on_hover_text("hooks off, settings restored, menu gone")
        .clicked()
    {
        shared.eject = true;
    }

    ui.add_space(8.0);
    if !shared.missing.is_empty() {
        ui.label(
            egui::RichText::new(format!("{} name(s) unresolved", shared.missing.len()))
                .size(10.5)
                .color(WARN),
        );
        egui::ScrollArea::vertical().max_height(90.0).show(ui, |ui| {
            for m in &shared.missing {
                ui.label(egui::RichText::new(m).size(9.5).color(DIM));
            }
        });
    }
}

// ---- styling ---------------------------------------------------------------

fn fonts() -> egui::FontDefinitions {
    let mut f = egui::FontDefinitions::default();
    let Ok(bytes) = std::fs::read(FONT_PATH) else {
        crate::log(&format!("font {FONT_PATH} not found; using the built-in face"));
        return f;
    };
    crate::log(&format!("loaded font: {} bytes", bytes.len()));
    f.font_data
        .insert("monocraft".to_owned(), egui::FontData::from_owned(bytes));
    // Use it for both families: the whole menu should read as one typeface.
    for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
        f.families.entry(family).or_default().insert(0, "monocraft".to_owned());
    }
    f
}

fn style() -> egui::Style {
    let mut s = egui::Style::default();
    let mut v = egui::Visuals::dark();
    v.panel_fill = BG;
    v.window_fill = BG;
    v.window_rounding = Rounding::ZERO;
    v.window_shadow = egui::epaint::Shadow::NONE;
    v.window_stroke = Stroke::new(1.0, LINE);
    v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, TEXT);
    v.widgets.inactive.bg_fill = OFF;
    v.widgets.inactive.weak_bg_fill = OFF;
    v.widgets.inactive.fg_stroke = Stroke::new(1.0, DIM);
    v.widgets.hovered.bg_fill = Color32::from_rgb(70, 76, 88);
    v.widgets.hovered.fg_stroke = Stroke::new(1.0, TEXT);
    v.widgets.active.bg_fill = ACCENT;
    v.widgets.active.fg_stroke = Stroke::new(1.0, TEXT);
    v.selection.bg_fill = ACCENT.linear_multiply(0.35);
    v.selection.stroke = Stroke::new(1.0, ACCENT);
    s.visuals = v;
    s.spacing.item_spacing = egui::vec2(6.0, 4.0);
    s.spacing.slider_width = 130.0;
    s
}

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

/// Resolve a GL entry point: extensions come from wglGetProcAddress, core 1.1
/// functions only from the opengl32 export table.
fn gl_proc(name: &str) -> *const std::ffi::c_void {
    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);
            // wglGetProcAddress reports failure as any of these, not just null.
            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(),
        }
    }
}

/// The slice of GL state egui touches, 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 egui assumes.
    unsafe fn neutralise(&self, gl: &glow::Context) {
        // The menu has to land on the framebuffer actually being presented.
        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);
        // egui writes plain sRGB values; 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 object bound here overrides the font texture's own
        // parameters, which renders the entire menu flat black.
        gl.bind_sampler(0, None);
        // With a pixel-unpack buffer bound, every texture upload reads from
        // that buffer instead of our pixels: 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={} align={} rowLen={} \
             srgb={} blend={} depth={} cull={} scissor={} stencil={}",
            self.draw_framebuffer,
            self.program,
            self.vao,
            self.sampler0,
            self.unpack_buffer,
            self.unpack_alignment,
            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);
    }
}