From 6b5425ebd35c28e20680f5f28c887b705827ba1c Mon Sep 17 00:00:00 2001 From: salmonik Date: Tue, 27 Oct 2026 18:53:32 +0100 Subject: GUI: add on-screen toasts and atomic config writes - Toast queue drawn in the top-right by the overlay, independent of the GUI being open. Info/warn/error colors and 6/9/12 s lifetimes; timers start on the first painted frame so startup messages are not missed. - Queue a load toast after install_menu, plus warnings/errors for missing modules/interfaces/netvars, input hooks, fonts, ISurface, server datamap, and spread seed support. - Toast on config reload, reset, bind/unbind and config save failure. - Config saves now write `config.tmp`, flush, then rename it over the real file; failures leave the previous config intact. - config_load reports whether a file was found so Reload can distinguish a successful reload from a missing file. - Update CONTEXT and ISURFACE_STATUS for the single GUI, toasts and atomic config writes. --- CONTEXT.md | 7 ++ src/ISURFACE_STATUS.md | 22 ++--- src/cinput.cpp | 3 +- src/gui.cpp | 217 ++++++++++++++++++++++++++++++++++++++++++++++++- src/hl2h.cpp | 25 +++++- src/hl2h.h | 9 ++ src/menu.cpp | 49 +++++++++-- src/server.cpp | 1 + 8 files changed, 309 insertions(+), 24 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 5e0e4b2..78f7c75 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -116,6 +116,11 @@ vocabulary / decisions. and ESP/FOV circle/speed graph keep drawing. No invulnerability. - **Native cursor**: on open, leave SDL relative-mouse mode and show the OS cursor; restore on close. Absolute mouse coordinates are used. +- **Toast**: transient top-right notification drawn by the overlay even when + the GUI is closed. Info/warn/error colors; lifetimes 6/9/12 s. Toasts queued + before the first paint (load errors) start their timer when first drawn. + Used for the load message, config problems, missing interfaces/netvars, + bind changes, Reload and Reset. - **Bind capture**: click Bind, press a key or any mouse button; Esc cancels capture but does not close the GUI; the small x clears the bind (None = always-on). Insert is reserved for the GUI. @@ -134,6 +139,8 @@ vocabulary / decisions. `x`, `y`, `category` and an `expanded` bitmask (card index order). Every feature now persists an `active` and `bind` pair, including movement, melee and spread. +- Config writes are atomic: `config.tmp` is written and flushed, then + `rename()`d over the real file. A failed write raises an error toast. ## Bones / ESP diff --git a/src/ISURFACE_STATUS.md b/src/ISURFACE_STATUS.md index 00a7adf..d553af0 100644 --- a/src/ISURFACE_STATUS.md +++ b/src/ISURFACE_STATUS.md @@ -1,6 +1,6 @@ # HL2hake ISurface Drawing — Status Report -## Date: 2026-09-12, updated 2026-10-26 +## Date: 2026-09-12, updated 2026-10-27 ## Result: WORKING @@ -67,21 +67,21 @@ fc-match findings (system fontconfig): ## Menu -- Starts fully collapsed on first open; `Insert` toggles it. -- Tree: HL2hake -> Bunnyhop (Active, Auto-strafe), ABH (Active), ESP (Active, - Show neutral/healthbar/healthnum/name/skeleton/box), Aimbot (Active, Bind, - Vischeck, Bones, Smoothness), Triggerbot (Active, Bind), Silent aim - (Active, Bind). Children indented by `g_depth[]` (+12px/level). -- Right-aligned state at x=270: `[on]`/`[off]` for toggles, `[keyname]` for - binds, `[press key]` while binding, `N deg/tick` for values. +- Custom immediate-mode GUI; `Insert` is the only toggle. +- Sidebar: Movement / Combat / Visuals / Weapons / System. Cards show + Active + Bind and collapsible settings; System has config path, build info, + Reload and two-click Reset. - Feature gating: `feature_active(int)` (menu.cpp) — active AND (bind==0 or key held), used by `do_move` and the aim features. -- Config: `$XDG_CONFIG_HOME/hl2hake/config` (fallback `~/.config/hl2hake/config`). +- Layout (`x`, `y`, `category`, `expanded` bitmask) persists under `[gui]`. +- Config: `$XDG_CONFIG_HOME/hl2hake/config` (fallback `~/.config/hl2hake/config`); + writes are atomic (`.tmp` + rename). +- Toasts are drawn by `gui.cpp` even when the menu is closed. ## Build / Run -- `./build.sh`: g++ `-m32 -O2 -std=c++17 -fPIC -shared`, 9 TUs - (`hl2h modules cinput netvars menu esp server bones vischeck`), `-ldl`, +- `./build.sh`: g++ `-m32 -O2 -std=c++17 -fPIC -shared`, 10 TUs + (`hl2h modules cinput netvars menu gui esp server bones vischeck`), `-ldl`, `-Wall -Wextra -Werror` (all warnings fatal). - `./play.sh -windowed +map d1_trainstation_01` (Steam runtime wrapper, `LD_PRELOAD=libhl2hake.so`). diff --git a/src/cinput.cpp b/src/cinput.cpp index ad7face..f466c53 100644 --- a/src/cinput.cpp +++ b/src/cinput.cpp @@ -228,7 +228,8 @@ bool find_input_global() { logf("CInput* = %#lx (ptr var %#lx, vfunc[3] %#lx)", (unsigned long)input, (unsigned long)var, (unsigned long)f3); g_input = input; - install_input_hook(); + if (!install_input_hook()) + toastf(TOAST_ERROR, "Input vtable hook failed; features disabled"); return true; } } diff --git a/src/gui.cpp b/src/gui.cpp index 132068e..0d5b458 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -6,6 +6,7 @@ #include "hl2h.h" #include +#include #include #include #include @@ -136,6 +137,7 @@ static void gui_init_sdl() { g_sdl_ready = sd_get_keys && sd_get_mouse; if (!g_sdl_ready) { logf("gui: SDL polling unavailable"); + toastf(TOAST_ERROR, "GUI input unavailable: SDL not found"); return; } if (sd_add_watch) @@ -406,6 +408,56 @@ static float now_sec() { return (float)ts.tv_sec + (float)ts.tv_nsec * 1e-9f; } +// --------------------------------------------------------------------------- +// Toasts (shown even when the GUI is closed) +// --------------------------------------------------------------------------- + +struct Toast { + char text[256]; + int level; + double born; // 0 = waiting for the first draw + double ttl; +}; + +static Toast g_toasts[8]; +static int g_ntoasts = 0; +static pthread_mutex_t g_toast_mu = PTHREAD_MUTEX_INITIALIZER; + +void toastf(int level, const char *fmt, ...) { + char buf[256]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + + pthread_mutex_lock(&g_toast_mu); + for (int i = 0; i < g_ntoasts; i++) { + if (strcmp(g_toasts[i].text, buf) == 0) { + g_toasts[i].level = level; + g_toasts[i].born = 0.0; + pthread_mutex_unlock(&g_toast_mu); + return; + } + } + if (g_ntoasts == 8) { + memmove(&g_toasts[0], &g_toasts[1], sizeof(Toast) * 7); + g_ntoasts = 7; + } + Toast &t = g_toasts[g_ntoasts++]; + snprintf(t.text, sizeof(t.text), "%s", buf); + t.level = level; + t.born = 0.0; + t.ttl = level == TOAST_ERROR ? 12.0 : level == TOAST_WARN ? 9.0 : 6.0; + pthread_mutex_unlock(&g_toast_mu); +} + +bool gui_toasts_active() { + pthread_mutex_lock(&g_toast_mu); + bool active = g_ntoasts > 0; + pthread_mutex_unlock(&g_toast_mu); + return active; +} + static void ensure_fonts() { if (g_font_body && fabsf(g_font_scale - g_scale) < 0.001f) return; @@ -437,8 +489,10 @@ static void ensure_fonts() { } if (!g_font_title) g_font_title = g_font_body; - if (!g_font_body) + if (!g_font_body) { logf("gui: no usable body font"); + toastf(TOAST_ERROR, "No usable GUI font"); + } } static int text_w(const char *s, int tall) { @@ -698,6 +752,8 @@ static void handle_capture(const unsigned char *keys) { g_mouse_rclick = false; g_capture_card = -1; config_save(); + toastf(TOAST_INFO, "%s bound to %s", card.title, + scancode_name(card.feat->bind)); return; } for (int k = 0; k < 512; k++) { @@ -709,6 +765,9 @@ static void handle_capture(const unsigned char *keys) { card.feat->bind = k; g_capture_card = -1; config_save(); + if (card.feat) + toastf(TOAST_INFO, "%s bound to %s", card.title, + scancode_name(card.feat->bind)); return; } } @@ -795,6 +854,7 @@ static void handle_mouse_click() { if (mx >= bind.x + bind.w - S(18.0f)) { c.feat->bind = 0; config_save(); + toastf(TOAST_INFO, "%s unbound (always-on)", c.title); return; } g_capture_card = i; @@ -844,6 +904,7 @@ static void handle_mouse_click() { config_reset_defaults(); g_reset_armed = false; logf("gui: defaults restored"); + toastf(TOAST_INFO, "Feature defaults restored"); } else { g_reset_armed = true; g_reset_armed_at = now; @@ -1198,6 +1259,160 @@ static void draw_tooltip() { inner.w, C_TEXT); } +// --------------------------------------------------------------------------- +// Toast drawing +// --------------------------------------------------------------------------- + +static int wrap_toast(const char *text, int max_w, int tall, + char lines[][256], int max_lines) { + int n = 0; + char cur[256] = {}; + bool truncated = false; + const char *p = text; + while (*p) { + while (*p == ' ') + p++; + if (!*p) + break; + const char *start = p; + while (*p && *p != ' ') + p++; + size_t wl = (size_t)(p - start); + if (wl > 120) + wl = 120; + char word[128]; + memcpy(word, start, wl); + word[wl] = 0; + + char cand[256]; + if (cur[0]) + snprintf(cand, sizeof(cand), "%s %s", cur, word); + else + snprintf(cand, sizeof(cand), "%s", word); + + if (text_w(cand, tall) <= max_w || !cur[0]) { + snprintf(cur, sizeof(cur), "%s", cand); + if (text_w(cur, tall) > max_w) { + // A single word longer than the panel: hard-truncate it. + int len = (int)strlen(cur); + while (len > 3 && text_w(cur, tall) > max_w) + cur[--len] = 0; + if (len > 3) { + cur[len - 3] = '.'; + cur[len - 2] = '.'; + cur[len - 1] = '.'; + } + truncated = true; + } + } else { + if (n < max_lines) + snprintf(lines[n++], 256, "%s", cur); + else + truncated = true; + snprintf(cur, sizeof(cur), "%s", word); + } + } + if (cur[0]) { + if (n < max_lines) + snprintf(lines[n++], 256, "%s", cur); + else + truncated = true; + } + if (truncated && n > 0) { + char *last = lines[n - 1]; + int len = (int)strlen(last); + if (len + 3 < 256) { + last[len++] = '.'; + last[len++] = '.'; + last[len++] = '.'; + last[len] = 0; + } + } + return n; +} + +void gui_draw_toasts() { + if (!g_surface) + return; + ensure_fonts(); + if (!g_font_body || g_scr_w <= 0 || g_scr_h <= 0) + return; + + Toast local[8]; + int n = 0; + double now = now_sec(); + pthread_mutex_lock(&g_toast_mu); + for (int i = 0; i < g_ntoasts; i++) { + Toast &t = g_toasts[i]; + if (t.born == 0.0) + t.born = now; + if (now - t.born > t.ttl) + continue; + local[n++] = t; + } + g_ntoasts = 0; + for (int i = 0; i < n; i++) + g_toasts[g_ntoasts++] = local[i]; + pthread_mutex_unlock(&g_toast_mu); + + int margin = S(16.0f); + int max_w = S(420.0f); + if (max_w > g_scr_w - margin * 2) + max_w = g_scr_w - margin * 2; + int y = margin; + for (int i = 0; i < n; i++) { + const Toast &t = local[i]; + float age = (float)(now - t.born); + float a = 1.0f; + if (age < 0.2f) + a = age / 0.2f; + else if (age > t.ttl - 0.5f) + a = (float)((t.ttl - age) / 0.5); + if (a < 0.0f) a = 0.0f; + if (a > 1.0f) a = 1.0f; + int alpha = (int)(a * 255.0f); + + char lines[4][256]; + int nl = wrap_toast(t.text, max_w, g_body_h, lines, 4); + if (nl == 0) + continue; + int line_w = 0; + for (int j = 0; j < nl; j++) { + int w = text_w(lines[j], g_body_h); + if (w > line_w) + line_w = w; + } + int pad = S(12.0f); + int line_h = g_body_h + S(4.0f); + int panel_w = line_w + pad * 2 + S(6.0f); + int panel_h = nl * line_h + pad * 2 - S(4.0f); + int x = g_scr_w - margin - panel_w; + if (x < margin) + x = margin; + + Col accent = t.level == TOAST_ERROR + ? Col{240, 90, 90, 255} + : t.level == TOAST_WARN ? Col{255, 200, 80, 255} + : C_ACCENT; + fill(Rect{x, y, panel_w, panel_h}, Col{16, 18, 22, (int)(235 * a)}); + outline(Rect{x, y, panel_w, panel_h}, Col{accent.r, accent.g, accent.b, alpha}); + fill(Rect{x, y, S(4.0f), panel_h}, + Col{accent.r, accent.g, accent.b, alpha}); + s_text_font(g_font_body); + s_text_color(C_TEXT.r, C_TEXT.g, C_TEXT.b, alpha); + for (int j = 0; j < nl; j++) { + s_text_pos(x + pad + S(6.0f), y + pad + j * line_h); + wchar_t w[256]; + size_t wn = mbstowcs(w, lines[j], 255); + if (wn == (size_t)-1) + continue; + w[wn] = 0; + s_print(w, (int)wn); + } + y += panel_h + S(8.0f); + } +} + void gui_draw() { if (!g_open || !g_surface) return; diff --git a/src/hl2h.cpp b/src/hl2h.cpp index 1460577..c51d35e 100644 --- a/src/hl2h.cpp +++ b/src/hl2h.cpp @@ -467,7 +467,8 @@ static void hkdSrvRandomSeed(int seed) { } static void install_seed_hook() { - if (g_seed_hook_installed || !g_server.valid()) + static bool failed = false; + if (g_seed_hook_installed || failed || !g_server.valid()) return; g_srv_base = g_server.ranges[0].lo; uintptr_t slot = g_srv_base + 0xce1348; // R_386_JUMP_SLOT RandomSeed @@ -478,7 +479,9 @@ static void install_seed_hook() { if (!g_real_random_seed) g_real_random_seed = (void (*)(int))dlsym(RTLD_DEFAULT, "RandomSeed"); if (!g_real_random_seed) { + failed = true; logf("seed hook: real RandomSeed not found"); + toastf(TOAST_WARN, "Spread seed hook unavailable"); return; } mprotect((void *)(slot & ~(uintptr_t)0xFFF), 0x1000, @@ -808,6 +811,7 @@ static void *setup_thread(void *) { } if (!g_client.valid() || !g_engine.valid()) { logf("client.so/engine.so never appeared"); + toastf(TOAST_ERROR, "client.so/engine.so not found; hl2hake inactive"); return nullptr; } logf("client.so: %d mapped range(s), %s", (int)g_client.ranges.size(), @@ -822,6 +826,7 @@ static void *setup_thread(void *) { auto ef = (CreateInterfaceFn)dlsym(eh, "CreateInterface"); if (!cf || !ef) { logf("CreateInterface not found"); + toastf(TOAST_ERROR, "CreateInterface not found; hl2hake inactive"); return nullptr; } @@ -840,6 +845,7 @@ static void *setup_thread(void *) { logf("interfaces unresolved: VClient017=%p VClientEntityList003=%p " "VEngineClient014=%p", g_iface.client, g_iface.entitylist, g_iface.engine); + toastf(TOAST_ERROR, "Game interfaces unresolved; hl2hake inactive"); return nullptr; } logf("interfaces: VClient017=%p VClientEntityList003=%p " @@ -856,12 +862,27 @@ static void *setup_thread(void *) { g_iface.engineserver, g_iface.debugoverlay, g_iface.enginetrace); resolve_netvars(); - if (!find_input_global()) + if (!g_net.ok()) + toastf(TOAST_ERROR, "Netvars incomplete; features may be disabled"); + else if (g_net.absvelocity < 0) + toastf(TOAST_WARN, "Jumpbug assist disabled: velocity netvar missing"); + if (!g_iface.engineserver) + toastf(TOAST_WARN, "Server interfaces missing; jumpbug/spread limited"); + if (!g_iface.debugoverlay) + toastf(TOAST_WARN, "Debug overlay missing; ESP projection uses fallback"); + if (!g_iface.enginetrace) + toastf(TOAST_WARN, "Engine trace missing; vischeck fails open"); + + if (!find_input_global()) { + toastf(TOAST_ERROR, "Input hook failed; movement features disabled"); return nullptr; + } init_conmsg(); install_hook(); install_menu(); + if (g_surface) + toastf(TOAST_INFO, "hl2hake loaded - Insert opens the menu"); return nullptr; } diff --git a/src/hl2h.h b/src/hl2h.h index 0f03925..e390aa0 100644 --- a/src/hl2h.h +++ b/src/hl2h.h @@ -22,6 +22,15 @@ constexpr float PI_F = 3.14159265f; void logf(const char *fmt, ...); +// --------------------------------------------------------------------------- +// on-screen toasts +// --------------------------------------------------------------------------- + +enum ToastLevel { TOAST_INFO = 0, TOAST_WARN, TOAST_ERROR }; +void toastf(int level, const char *fmt, ...); +bool gui_toasts_active(); +void gui_draw_toasts(); + // --------------------------------------------------------------------------- // module discovery // --------------------------------------------------------------------------- diff --git a/src/menu.cpp b/src/menu.cpp index 5fc325e..2021218 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -116,12 +116,14 @@ void register_bundled_font() { } if (path.empty()) { logf("font: bundled LiberationSans-Regular.ttf not found"); + toastf(TOAST_WARN, "Bundled font missing; overlay may not render"); return; } void *fc = dlopen("libfontconfig.so.1", RTLD_LAZY | RTLD_GLOBAL); if (!fc) { logf("font: libfontconfig.so.1 not available"); + toastf(TOAST_WARN, "fontconfig unavailable; overlay font not registered"); return; } using InitFn = int (*)(); @@ -132,20 +134,27 @@ void register_bundled_font() { auto add = (AddFileFn)dlsym(fc, "FcConfigAppFontAddFile"); if (!init || !get || !add) { logf("font: fontconfig symbols missing"); + toastf(TOAST_WARN, "fontconfig symbols missing; overlay font not registered"); return; } init(); void *cfg = get(); if (cfg && add(cfg, path.c_str())) logf("font: registered %s", path.c_str()); - else + else { logf("font: failed to register %s", path.c_str()); + toastf(TOAST_WARN, "Could not register the overlay font"); + } } void config_save() { - FILE *f = fopen(config_path().c_str(), "w"); - if (!f) + std::string path = config_path(); + std::string tmp = path + ".tmp"; + FILE *f = fopen(tmp.c_str(), "w"); + if (!f) { + toastf(TOAST_ERROR, "Config save failed: %s", path.c_str()); return; + } fprintf(f, "[bhop]\nactive=%d\nbind=%d\nautostrafe=%d\n", g_bhop.active, g_bhop.bind, g_bhop_autostrafe); fprintf(f, "[abh]\nactive=%d\nbind=%d\n", g_abh.active, g_abh.bind); @@ -175,13 +184,23 @@ void config_save() { g_esp_opt[ESP_OPT_SHOW_SKELETON], g_esp_opt[ESP_OPT_SHOW_BOX], g_esp_opt[ESP_OPT_SHOW_SPEEDGRAPH]); gui_config_write(f); - fclose(f); + bool ok = ferror(f) == 0; + if (fflush(f) != 0) + ok = false; + if (ok) + (void)fsync(fileno(f)); // best effort; the rename is what matters + if (fclose(f) != 0) + ok = false; + if (!ok || rename(tmp.c_str(), path.c_str()) != 0) { + remove(tmp.c_str()); + toastf(TOAST_ERROR, "Config save failed: %s", path.c_str()); + } } -static void config_load() { +static bool config_load() { FILE *f = fopen(config_path().c_str(), "r"); if (!f) - return; + return false; char line[256]; FeatureState *cur = nullptr; bool in_esp = false, in_bhop = false, in_aim = false, in_silent = false, @@ -288,9 +307,15 @@ static void config_load() { } } fclose(f); + return true; } -void config_reload() { config_load(); } +void config_reload() { + if (config_load()) + toastf(TOAST_INFO, "Config reloaded"); + else + toastf(TOAST_WARN, "Config not found; kept current settings"); +} const char *config_path_cstr() { static std::string path; @@ -583,7 +608,8 @@ void outer_draw() { // HL2H_DRAW_TEST=1 draws a probe rect without needing the GUI open static bool draw_test = getenv("HL2H_DRAW_TEST") != nullptr; bool esp_on = feature_active(FEAT_ESP); - if (!g_surface || (!gui_is_open() && !draw_test && !esp_on)) + bool toasts = gui_toasts_active(); + if (!g_surface || (!gui_is_open() && !draw_test && !esp_on && !toasts)) return; // Level/menu -> game transitions can invalidate the VGUI font handle. @@ -622,6 +648,7 @@ void outer_draw() { } if (!font_ok) { logf("outer_draw: no usable font"); + toastf(TOAST_ERROR, "No usable overlay font"); return; } } @@ -639,6 +666,8 @@ void outer_draw() { } if (gui_is_open()) gui_draw(); + if (toasts) + gui_draw_toasts(); surf_pop_make_current(panel); } @@ -697,13 +726,15 @@ static ISurface *find_surface_interface() { void install_menu() { register_bundled_font(); logf("config: %s", config_path().c_str()); - config_load(); + if (!config_load()) + toastf(TOAST_INFO, "No config found; using defaults"); gui_config_init(); // resolve ISurface once (used by the DrawPrintText hook in hl2h.cpp and // by outer_draw). DrawPrintText hook is installed from hkdCreateMove. g_surface = find_surface_interface(); if (!g_surface) { logf("ISurface not found; menu disabled"); + toastf(TOAST_ERROR, "Overlay unavailable: ISurface not found"); return; } logf("ISurface = %p", g_surface); diff --git a/src/server.cpp b/src/server.cpp index 896ce3e..cfb4dd1 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -123,6 +123,7 @@ bool resolve_server_netvars() { fail_logged = true; logf("server datamap: scan failed (server.so at %s)", g_server.path.c_str()); + toastf(TOAST_WARN, "Server datamap scan failed; jumpbug assist limited"); } return g_srv_ready; } -- cgit v1.2.3