# hl2h fix plan Derived from a full audit of `src/` (12,243 lines) plus `build.sh` / `play.sh`. Every item below was re-verified against the working tree; claims that did not survive verification are listed in [§6](#6-verified-not-a-bug--do-not-fix) so nobody wastes time on them. Ordering: **P0 → P1 → P2 → P3**. Each phase leaves the cheat playable. Work inside a phase is independent enough to parallelise; cross-phase dependencies are noted. --- ## 0. Verification ground rules - Line numbers are from the audited tree; re-check after edits. - "Fix" means *smallest change that removes the failure mode*, not a rewrite. Rewrites belong to P3. - Every P0/P1 item must be reproducible before the fix and re-checked after. For logic fixes, extract the function into `tests/` (see §5) rather than relying on eyeballing a running game. - Anything touching hook install/uninstall goes in with a readiness guard (P0-1), otherwise later phases regress the race. --- ## 1. P0 — crash, UB, and startup races These are the items that produce the existing `crash` log and the highest risk of nondeterministic hangs. Do them first, in this order. ### P0-1 Hook readiness gate and init ordering *(M)* **Where:** `hl2h.cpp:1185` (`chams_init`), `:1204-1206` (`init_conmsg`, `install_hook`, `install_menu`), `menu.cpp:1224` (`config_load`), `menu.cpp:1229` (ISurface discovery). **Problem:** `CHLClient` and `CInput` hooks are installed *before* config load and ISurface discovery. The game thread can enter `hkdCreateMove` / `hkdInputCreateMove` / `hkdModelExecute` while `setup_thread` is still writing feature globals and `g_surface`. Every hook is also installed lazily from different threads (surface hooks from `CreateMove`, studio-prop hooks from the paint path), so "is init done" is not observable. **Fix:** 1. Add `std::atomic g_init_stage {0}` (`0` = nothing, `1` = interfaces + netvars, `2` = config + surface + fonts, `3` = hooks live). 2. Move `install_hook()`, `install_input_hook()`, and the seed/uniform patches to *after* `install_menu()` completes. 3. Every hook entry point starts with `if (g_init_stage.load(std::memory_order_acquire) < 3) return call_original(...);` — hooks stay installed, they just pass through until ready. 4. Keep lazy surface/studio installs, but have them check the stage instead of relying on "first `CreateMove` implies ready". 5. On any setup failure, set stage to a terminal `-1` and log once; hooks then pass through forever instead of half-working. **Verify:** start the game straight into a map (`+map`) and into the main menu, 10× each, with `HL2H_DEBUG=1`; `hook installed:` lines must now appear after `config loaded:` / `surface resolved:`. No crash in `crash`-style backtraces. --- ### P0-2 Module snapshots are published under a lock but read unlocked *(M)* **Where:** writer `modules.cpp:78-84`; readers `hl2h.h:47-52` (`Module::contains` / `valid` / `.ranges` / `.path`), `netvars.cpp:9`, `server.cpp:71,95,147`, `hl2h.cpp:1104-1109`. Re-entry: `server.cpp:147-148` calls `find_modules()` from the game thread while `setup_thread` reads `g_client`/`g_engine`. **Problem:** `find_modules()` move-assigns `g_client`/`g_engine`/`g_server`, freeing `std::vector` and `std::string` buffers while another thread iterates them. Locking only the writer is worse than no lock — it hides the race. **Fix:** 1. Make `Module` immutable after publish. Keep a `std::shared_ptr` and publish with `std::atomic_store(&g_modules, new_set)` (C++11 `shared_ptr` atomic free functions; C++17 build is fine). 2. Readers do `auto m = std::atomic_load(&g_modules);` once per function and use `m->client` for the whole body. No lock, no torn state. 3. Stop calling `find_modules()` from `server.cpp` / `netvars.cpp` on demand. Resolve once during init; on failure, retry from the setup thread only. If a late retry is genuinely needed, it must publish a new snapshot, never mutate the current one. **Verify:** run `resolve_server_netvars` with a deliberate 100 ms delay under `-fsanitize=thread` on a native (non-`-m32`) build of `modules.cpp` only if practical; otherwise add an assertion that `find_modules` is only called from the setup thread. --- ### P0-3 `%ebp` frame-pointer dereference in the random hooks *(M)* **Where:** `hl2h.cpp:497-501` (`hkdSrvRandomSeed`) and `:606-610` (`hkdUniformRandomFloat`). `build.sh` has `-fno-omit-frame-pointer`, but that only constrains *this* `.so`; the callers are Valve's `server.so` and `libvstdlib.so`. **Problem:** `frame = *(void **)ebp; self = *(void **)(frame + 8)` assumes a standard frame chain and a first-argument-at-`+8` layout in code we do not control. A non-frame-pointer caller, tail call, or different convention segfaults immediately. **Fix (in order of preference):** 1. Drop the `self` derivation entirely. `g_spread_rand_left` only needs to be armed while the *local player* fires; `hkdSrvRandomSeed` already knows the seed window is being requested from the local server entity path (`g_local_server_player` is available via `server_active_weapon`-style lookups). Arm the window on `CreateMove` when `IN_ATTACK` is set and the active weapon has spread, then let the hook only substitute the seed. 2. If the frame walk stays, hard-validate before both dereferences: `contains_range(ebp, 4) && (ebp & 3) == 0`, same for `frame` and `frame + 8`, and require the resulting `self` to be inside a mapped module or the entity-list heap range. Bail to the original on any failure. 3. Never leave `g_spread_rand_left` armed across ticks: reset it in `do_move` entry and on `CreateMove` return. **Verify:** pistols/SMG/AR2/shotgun/crossbow, 20 shots each, log `seed in / seed out / fallback` counters; zero fallbacks on a normal map, no crash on maps with scripted weapons. --- ### P0-4 Netvar scan bounds validation *(S)* **Where:** `netvars.cpp:9-19` (`module_string` checks only the first byte), `:33-39` (derefs `table+0`, `table+4`, `table+0xC` before validating `table`), `:317` (root pointer from `cc+0xC` unvalidated), plus the nested `ptable` path. **Fix:** add one helper next to `Module`: ```cpp bool module_range(uintptr_t p, size_t n); // p and p+n-1 inside one range ``` Use it for every pointer before the first dereference and for every string scan (`module_range(p, outn)`), then keep the existing printable-char check. Add an `assert(g_client.contains(table))`-style precondition in `collect_netvars` so future callers fail loudly instead of reading garbage. **Verify:** `resolve_netvars` logs "resolved N/M" on main menu, map load, and map change; N/M must not change. No crash when `server.so` is absent. --- ### P0-5 Preserve the Itanium vtable header when hooking *(S)* **Where:** `hl2h.cpp:1060-1068`, `cinput.cpp:153-162`, `studio.cpp:544-548`, `studio.cpp:584-598`. **Problem:** all four sites do `new void*[N]; memcpy(new, vt, N*4); ptr = new;` so `new[-1]` (typeinfo) and `new[-2]` (offset-to-top) are heap metadata. The cheat never calls RTTI on these pointers, so this is currently latent, but the engine may, and the fix costs nothing. **Fix:** allocate `N+2`, set `dst[0]=vt[-2]; dst[1]=vt[-1];` and memcpy the slots at `dst+2`; hand out `dst+2`. Add a tiny helper `void** clone_vtable(void** vt, size_t slots)` used by all four sites — this also removes the duplicated 256/32/49 magic sizes. **Verify:** hooks still fire (`logd` in each handler); no behaviour change expected. Optional: `nm`/`objdump` sanity that the new header words match. --- ### P0-6 `config_load` null deref on malformed config *(S)* **Where:** `menu.cpp:347-385` sets `cur = nullptr` for `[view]`, `[movement]`, `[chams_*]`; `menu.cpp:643-646` dereferences it unconditionally. **Reachability:** `config_save` never writes `active`/`bind` there, so this is hand-edited/corrupt-file only. Still a one-line crash. **Fix:** the section loop should carry an explicit "target kind" enum (`TARGET_FEATURE`, `TARGET_SCALARS`, `TARGET_CHAMS`, `TARGET_IGNORE`) and the key handler should switch on it, not on `cur`. Minimum change: `if (!cur) continue;` before the `active`/`bind` branches. **Verify:** add the malformed cases to `tests/` (§5) — `[view]\nactive=1`, `[chams_vm]\nbind=3`, truncated last line, no trailing newline. --- ### P0-7 Signed modulo on `seq` in `cinput.cpp` *(S)* **Where:** `cinput.cpp:61`, `:72-73`, `:141`. **Problem:** `seq % 90` is negative for negative `seq`; `(size_t)` then makes a huge index. The `command_number` sanity check happens *after* the pointer is formed and dereferenced. **Fix:** `if (seq < 0) return;` at the top of `usercmd_get` / `fix_verified_crc` / `hkdInputCreateMove`, and use `((unsigned)seq) % 90u` throughout. **Verify:** assertion `seq >= 0`; no behaviour change in normal play. --- ### P0-8 Defensive guards with real (if unlikely) triggers *(S, batch)* Small, independent, cheap: | Item | Where | Fix | |---|---|---| | Unbounded parent walk | `bones.cpp:419-423` | 64-iteration cap, same as the humanoid branch | | `mprotect` unchecked, `PROT_EXEC` on data, unaligned slot | `hl2h.cpp:571-573`, `:646-651` | check return; touch the full page range spanning the slot; `PROT_READ\|PROT_WRITE` only; restore original prot after the write | | `server.so` `.bss` tail not absorbed | `modules.cpp:50-58` | add the `server` branch alongside client/engine | | studio `name[64]` read as 128 | `studio.cpp:64-70` | `strnlen(name, 64)`; note this reads adjacent header fields, not OOB memory, so it is a correctness fix only | | `bone_nearest_core_parent` / `build_core_mask` recomputed every tick | `bones.cpp:346-362` | recompute only when `hdr`/`count` changes | **Verify:** map with many NPCs (e.g. `d2_coast_03`), skeleton ESP on, 10 min; `bones_update` budget logged once per second must stay flat. --- ## 2. P1 — gameplay and data-correctness bugs ### P1-1 Dormant-entity handling *(M)* **Where:** none of `esp_collect` (`esp.cpp:436`), `select_aim_target` (`:916`), `melee_aimbot_apply` (`:1375`), `triggerbot_apply` (`:1732`), `items_collect` (`:2740`, `:2801`), `projs_collect` (`:3090`), `prop_tint_update` (`:1797`) checks `IsDormant()`. **Fix:** one helper `bool entity_dormant(void* ent)` in the entity-query layer (P3 target: `entity_query.cpp`), plus a call at the top of each loop. For aimbot/triggerbot/melee this is a correctness fix (stale frozen positions); for items/projectiles it is a ghost-marker fix. Do **not** add it to `bones_update` alone — gate at collection so downstream code never sees them (also removes the "SetupBones on a dormant entity" class of crash). **Verify:** walk out of an NPC's PVS; box/arrow/skeleton disappears within one tick, aimbot stops acquiring it, and no `schema`/studio asserts appear in console. ### P1-2 `srv_checked` permanent latch *(S)* **Where:** `esp.cpp:433-434`, `:459-464`. Reset block exists at `hl2h.cpp:784-793` but does not touch it. **Fix:** move the two statics into the map-change reset registry (P3-4; a temporary `esp_reset_session()` called from `hl2h.cpp:784` is enough now), and only latch when `cli_mi > 0 && srv_mi > 0`. Keep re-checking per NPC for the first ~30 s after a map load rather than once ever. **Verify:** load map → immediately look at an NPC before it finishes spawning → health bar must appear once the model index syncs. ### P1-3 Bone cache correctness *(M)* **Where:** `bones.cpp:103` (`g_entries[64]`), `:121-125` (`g_next++ % 64`), `:322-330` (early returns leave `valid == true`), `:494-497` (`head_position` returns on the first name match even if invalid). **Fix:** 1. Size the cache for the real upper bound (128 targets) with headroom: 256 entries, LRU by stamp, or a `std::unordered_map` keyed by entity index. Never evict an entry whose `stamp` is the current tick. 2. Set `e->valid = false` on *every* failure path, including `:322-330`. 3. `head_position`: `continue` instead of `return` when `hitbox_world` / `bone_pos` fails, so the fallback chain still runs. 4. Make `bones_update` return the cached entry pointer so callers cannot read a different (evicted) entry than the one that was validated. **Verify:** map with >64 NPCs (or a `npc_create` stress test); skeleton ESP must not flicker; `bones_count` must never return a count for an entity whose `SetupBones` failed this tick. ### P1-4 Vischeck: glass stepping and the entity filter *(M)* **Where:** `vischeck.cpp:179-214` (step 0.5 past `tr.endpos`), `:189-190` (`startsolid` → `false`), `:91-99` (`ShouldHitEntity` ignores only `self` and `target`). **Fix:** 1. Replace the fixed 0.5 step with "advance until outside the brush, up to N units, then continue", i.e. loop the trace forward while `startsolid` or the hit entity is still the same glass brush, capped at, say, 32 units and never past `point`. 2. `ShouldHitEntity` must also ignore the target's children: resolve `m_hOwnerEntity` (needs P1-7) and skip anything whose owner is `target`; that covers held weapons and `phys_bone_follower`. 3. Early-out when `start` is within epsilon of `point` instead of tracing backwards. **Verify:** shoot through `func_breakable` glass thick and thin, strider and gunship hitboxes, and a target holding a weapon — ESP visibility and aimbot vischeck must agree with manual LOS. ### P1-5 Aimbot selection and firing *(M)* **Where:** `esp.cpp:815-816` (hard `maxs < 0`), `:943-945` (fallback exists), `:949-953` (single `approx` prefilter), `:887` (lock with `enforce_fov=false`), `:1079-1084` (lead applied after vischeck/FOV), `:1199`, `:1532`, `:1751` (unconditional `IN_ATTACK`). **Fix:** 1. Delete `g_net.maxs < 0` from the gate at `:815`; rely on the `maxz = ... : 72.0f` fallback. 2. Prefilter: use a capsule/segment from the entity's `origin.z` to `origin.z + maxz` and test distance to the view ray, or simply widen `max_fov` by the angular radius of the AABB (`atan(maxRadius/dist)`) before rejecting. Then keep the exact per-hitbox test in `try_target`. 3. Lock: keep the lock but re-apply the FOV test with hysteresis (e.g. allow up to `max_fov * 1.5` before dropping), and key the lock on `(entindex, serial)` from `CBaseHandle`/`entindex` so slot reuse cannot transfer it. 4. Lead: compute `lead` first, then run vischeck and FOV on `lead`. If the lead point fails, fall back to the un-led point rather than firing at a wall. 5. Fire control: track the tick of the last synthetic `IN_ATTACK` and clear the button for one tick after each shot (a small state machine per weapon), or drive `IN_ATTACK` from `m_afButtonPressed` semantics so semi-autos see a release edge. **Verify:** per weapon class (pistol, SMG, AR2, shotgun, crossbow, RPG) run 10-shot strings at short/medium/long range, log chosen target id, hitbox, and whether FOV/vischeck passed on the *led* point. ### P1-6 Spread control gate *(S)* **Where:** `hl2h.cpp:505-507` and `:601-602` require `spread_no_spread_enabled() && spread_engine_enabled()`; `esp.cpp:1659` only requires `no_spread`. **Problem:** turning off "Engine spread override" disables the seed hook entirely, so "seed selection only" does nothing (documented at `hl2h.cpp:474`). **Fix:** decouple the two gates: the seed-substitution hook is armed by `spread_no_spread_enabled()`; `spread_engine_enabled()` only controls the `RandomFloat`→0 override. Update the GUI tooltip text if the semantics change. **Verify:** with engine override off, 20 shotgun blasts: pellet grouping must be tight and repeatable. ### P1-7 Resolve `m_hOwnerEntity` / `m_hOwner` *(S)* **Where:** `netvars.cpp` has no branch for either; `g_net.owner` / `g_net.weapon_owner` stay `-1` (`hl2h.h:99-100`) yet are read at `esp.cpp:2253-2259`, `:2272-2275`. **Fix:** add both name matches in the netvar walk (with the P0-4 range checks), then use them for (a) the vischeck filter in P1-4, (b) weapon ownership in `items_collect`. **Verify:** item ESP must stop marking freshly dropped weapons as owned; vischeck must stop being blocked by held weapons. ### P1-8 Math and movement correctness *(S)* | Item | Where | Fix | |---|---|---| | `vector_angles` pitch wraps to `[0,360)` | `hl2h.cpp:90-94` | return `[-90,90]`; normalise yaw to `[-180,180]`; delete the `.x = 0` workaround at `:865` | | Early `return` skips all features underwater | `hl2h.cpp:855-859` | set the underwater flag and `goto`/`break` past only the bhop section; never skip `note_modified()` | | `point_ground` ignores surface normal | `vischeck.cpp:273` | require `tr.normal.z >= 0.7f` like `ground_distance` (`:291`) | | `jb_force_next_distance` casts away `const`, cached `vz` goes stale | `hl2h.cpp:286-304`, `:334`, `:459` | drop the cached `vz`, re-read `vel[2]` after the call, or return the new value | | melee AABB has no lower bounds | `esp.cpp:1420-1431` | reject `minx/miny/minz` outside `±512` | | triggerbot O(4096) index scan | `esp.cpp:1729-1758` | use `IClientNetworkable::entindex()` on the hit entity | **Verify:** underwater jump+duck still bhops and still shoots; edge-jump at a steep-slope ledge; aimbot aims correctly when looking up (pitch sign). ### P1-9 Cache/table lifetime bugs *(S)* **Where:** `esp.cpp:3060-3073` (`proj_first_seen` 32 fixed slots, no eviction); `esp.cpp:2719-2774` (`owned_mask` OR'd within a map). **Fix:** replace `proj_first_seen` with a 64-entry ring keyed by `(entindex, serial)` that overwrites the oldest entry; recompute `owned_mask` from `server_player_weapon(local_idx, slot)` on every scan when the server interface is available (keep the OR as fallback only). **Verify:** throw >40 grenades on one map; fuse timers must stay correct. Drop and re-pick a weapon; item ESP must reclassify it. ### P1-10 Projectile lead source *(S)* **Where:** `esp.cpp:1045-1048` reads `C_BaseEntity::m_vecVelocity` on NPC targets, but Source only networks that field for the player (`baseentity.cpp:6665`), so NPC lead velocity is stale/zero. `g_srv_velocity` is resolved at `server.cpp:161` and never used. **Fix:** for NPC targets, read velocity through the server entity path (`g_srv_velocity`) and fall back to zero lead on failure. Grenades (`esp.cpp:3185-3187`) are fine — they do network velocity. **Verify:** crossbow/RPG lead on a moving combine at range; the led point should lead the NPC's actual movement. ### P1-11 Visual toggles gated by the wrong feature *(S)* - `draw_fov_circle` is inside `esp_draw` (`esp.cpp:2084`, gated by `FEAT_ESP` at `menu.cpp:1083`) so it shows with aimbot off and hides with ESP off. Move the call to a neutral draw path or gate it on `aimbot_active || silentaim_active`. - `draw_transitions` (`esp.cpp:2092`) has no setting at all. Add a toggle and default it off (its `transitions_scan` walks server entities every frame). - `draw_fov_circle` rejects `fov >= 89` (`esp.cpp:606`) while the slider allows 180 (`gui.cpp:371`). Clamp the slider to the drawable range, or render the cone as a screen-space wedge instead of projecting axis points. **Verify:** toggle ESP off with aimbot on → circle stays; toggle aimbot off with ESP on → circle gone; transitions toggle actually stops the wireframes. --- ## 3. P2 — rendering and GUI correctness ### P2-1 Draw exactly once per frame *(M)* **Where:** `hl2h.cpp:1008-1032`; guard is only `g_in_draw` (`:1003`), which prevents nesting, not repetition. Both `hkdDrawPrintText` (`:1017`) and `hkdPaintTraverseEx` (`:1027-1031`) call `outer_draw()`. **Fix:** pick one owner. Preferred: `PaintTraverseEx` filtered to `MatSystemTopPanel` (or the engine's root panel), and use `DrawPrintText` only as a fallback if no paint happened in the current frame (`last_paint_frame != g_frame_count`). Add a per-frame id so the guard is "already drew this frame", not "currently drawing". **Secondary (independent):** save and restore ISurface text state around `outer_draw()` — `m_hCurrentFont` (`this+0x290`), `drawColor` (`+0x24`), `textColor` (`+0x28`), `m_nTextX/Y` (`+0x2c/0x30`) — because `PushMakeCurrent`/`PopMakeCurrent` only unwinds the clip stack (`ISURFACE_STATUS.md:64-67`). **Verify:** alpha-255 world tint and a 120-alpha ESP box must look identical whether ESP is drawn once or with the old code; instrument a frame counter and assert exactly one `outer_draw` per frame. ### P2-2 Single source of truth for widget geometry *(S now, folds into P3-2)* **Where:** `gui.cpp:1347-1351` (`cr.x` includes `+S(10)`), `:1948` and `:1627` (track from `cr.x`), `:1854-1855` (drag range omits the `+S(10)` and is 10 px wider at the right edge). **Immediate fix:** compute the track rect once in a helper `slider_track(const Rect& cr)` and call it from `draw_slider`, `handle_mouse_click`, and the drag handler. Add the missing `+S(10)` in the drag range as a one-line stopgap if the helper is deferred. **Verify:** click a slider knob at the far left/right, drag across; value must track the cursor 1:1 and not jump on mouse-down. ### P2-3 Clipping correctness *(S)* | Bug | Where | Fix | |---|---|---| | `line()` has no clip | `gui.cpp:920` | add `const Rect* clip` and clip via the same Cohen–Sutherland helper used for rects; use it for the checkbox checkmark (`:1941-1942`) and picker cursors (`:1300-1301`, `:1313-1314`) | | `outline()` draws a border on the clip edge | `gui.cpp:911-918` | clip each of the four edges independently instead of outlining the clamped rect | | `text_in()` shifts text left instead of clipping | `gui.cpp:872-888` | honour `max_w`: binary-search the longest prefix that fits, append `…`; never move `x` left | **Verify:** scroll a card halfway past the content top/bottom — no border line through the card, no checkmark floating in the header, long dropdown labels truncate instead of overlapping the label column. ### P2-4 Pointer/click discipline in the GUI *(S)* **Where:** `gui.cpp:1501-1522` (dropdown dismissal falls through), `:1717-1772` (`handle_hover` ignores `g_dropdown_ctrl`). **Fix:** a single `consume_click()` result from the dropdown block; when it closes, return immediately. `handle_hover` early-outs while a dropdown, colour picker drag, or slider drag is active. Also close pickers/dropdowns on outside-click consistently (currently pickers need another colour control). **Verify:** open a dropdown over a toggle row and click the toggle; the dropdown must close and the toggle must not flip. ### P2-5 SDL interposition state tracking *(M)* **Where:** `gui.cpp:140-141` (`dlsym(RTLD_DEFAULT, ...)` resolves the cheat's own exported wrapper), `:2415-2434` (requests dropped while open), `:1382-1385` (restores the pre-open snapshot), `:1410-1412` (snapshot). **Fix:** 1. Resolve real symbols with `dlsym(RTLD_NEXT, ...)` in both wrappers (they already do internally at `:2417`/`:2430`; the public `sd_*` pointers must match). 2. Track *last requested* state while open: in the wrappers, record `g_game_wants_cursor` / `g_game_wants_relative` before forcing the GUI state, and restore those on close instead of the pre-open snapshot. 3. Don't drop all `SDL_MOUSEMOTION`; drop only the relative-motion deltas the game would consume, or key off `g_open` + "not over a GUI window". 4. Keep the `MOUSEWHEEL` watch but honour `direction == FLIPPED` (`SDL_MouseWheelEvent` at `+24`). **Verify:** open GUI on the main menu → load a map → close GUI; mouse look must work and the cursor must hide. Same sequence with alt-tab in the middle. ### P2-6 Chams material state *(M)* **Where:** `studio.cpp:267` (AlphaModulate set), `:282-291` (`chams_reset` never resets it), `:350-384` (forced override cleared for every pass, including shadow depth), `:494-510` (same `IMaterial*` refcounted 2–4×), `:122-131` (`hook_draw_model` never restores the previous modulation). **Fix:** save the previous material state (colour modulation, `$color`, `$alpha`, `AlphaModulate`) and restore it in `chams_reset`; dedupe material names in `chams_preload` (one `IncrementReferenceCount` per unique material); skip the override when the current pass is a shadow-depth draw (compare the engine's existing forced override, or check the draw flags before clearing); restore `SetColorModulation` after `hook_draw_model`. **Verify:** chams on/off repeatedly with a flashlight/shadow-casting map; shadows must not disappear; translucent chams must not stay semi-transparent after toggling off; static prop tint must not bleed onto other models. ### P2-7 Font lifecycle *(S)* **Where:** `gui.cpp:687-703` (create inside the family loop), `:704-713`/`:725-727` (reset on level/scale change), `menu.cpp:1107-1131` (same pattern). **Fix:** create once per `(scale, DPI, font file)` and keep the handles; try families by `AddGlyphSetToFont` on one handle where possible. Level changes must not reset fonts at all (VGUI fonts are global). On scale change, recreate once and log; accept the bounded leak because `ISurface` has no `DestroyFont`. **Verify:** die/respawn 20× and change UI scale 5×; log the `HFont` counter — it must not grow per level change. ### P2-8 Projection and resolution *(M)* **Where:** `esp.cpp:57-79` (`project_manual` ignores the aspect/(4/3) scaling Source applies at `view.cpp:1075-1083`, and ignores roll), `:2078`, `:2925`, `:3301` (screen size cached once). **Fix:** 1. Prefer `IVDebugOverlay::ScreenPosition` everywhere; keep manual projection only as a fallback, and in the fallback apply `tanHalfH *= aspect / (4.0f/3.0f)` and include the roll term from `g_cam_view.z`. 2. Refresh `g_scr_w`/`g_scr_h` every frame (or on `GetScreenSize` change) — mirror what `gui.cpp:928-930` already does. 3. Don't mix `world_to_screen` and `project_manual` across one segment (`esp.cpp:2006-2009`); pick one per segment. **Verify:** 16:9, 16:10, 21:9, and windowed resize mid-session: blast rings and trajectory arcs must stay glued to the world; no kink at the near plane. ### P2-9 Skybox lifetime *(M)* **Where:** `world.cpp:752-761` (in-place HDR linearisation), `:953-959` (`skybox_invalidate` keeps texture + pixels), `:699-705`/`:720-729` (originals stored without refcount), `:961-968` (`g_sky_tex = nullptr` leaks the procedural texture). **Fix:** keep a pristine copy (`g_sky_pixels_src`) and rebuild the working buffer on each apply; `AddRef` originals when recorded and release on invalidate; detach the regenerator and `DecrementReferenceCount` the old procedural texture on reload. Either implement the documented `SetUndefined()`/`Refresh()` path or update `CONTEXT.md:633-635` — right now the docs describe code that does not exist. **Verify:** HDR map → LDR map → HDR map; sky brightness must reset each time. No material-system assert on map change with a custom skybox. --- ## 4. P3 — architecture Do these only after P0/P1 are green. Each is behaviour-preserving and can land independently. Order is by bug-surface reduction per unit of risk. ### P3-1 Overlay layer: one ISurface wrapper *(M)* `menu.cpp` (`SURF_*`/`surf_*`), `esp.cpp` (`SurfSlot`/`surf_*`), `gui.cpp` (`GS_*`/`s_*`) each hardcode the same slots. Introduce `overlay.h/.cpp` with the slot table, `surface_*` primitives, text drawing, clip handling, and the `outer_draw()` dispatcher. Everything else calls it. This also gives P2-3/P2-1 one place to fix. ### P3-2 Declarative settings table *(M)* Replace the current six-place edit (header extern, definition, `config_save`, `config_load`, `config_reset_defaults`, `build_cards`) with one table: ```cpp struct SettingDesc { const char* section; const char* key; Type type; void* ptr; double def, min, max; const char* label; }; ``` Generate save/load/reset/clamp from it; `build_cards` consumes the same descriptors. Eliminates P0-6 and the duplicated clamp bounds, and makes new options cheap. ### P3-3 Split `esp.cpp` *(L, mechanical)* Target files, keeping current call graph: `entity_query.cpp` (class names, NPC relation, viewmodel/owned-weapon helpers — also the home for `entity_dormant` from P1-1), `combat.cpp` (aimbot/silent/triggerbot/melee/spread search), `esp.cpp` (boxes/skeleton/arrows/speed graph/transitions), `items.cpp`, `projectiles.cpp`. No behaviour change; move code verbatim, then fix includes. ### P3-4 Session-reset registry *(S)* One `session_reset()` called from the map-change path (`hl2h.cpp:784-793` already has the hook point) that clears every file-scope cache: `bones_clear`, `srv_checked`, `owned_mask`, `proj_first_seen`, bone ring buffer, `g_scr_*`, spread state, aim/silent locks, item/proj arrays, `g_map_last/prev`. Add a debug counter so a forgotten cache is visible. ### P3-5 Header split and dependency direction *(M)* Split `hl2h.h` into `common.h` / `core.h` / `settings.h` / `interfaces.h` / `netvars.h` / `server.h` / `entity_query.h` / `bones.h` / `trace.h` / `overlay.h` / `ui.h`. Enforce: features never include `hooks.h`/`init.h`; `overlay.h` is the only place that knows ISurface slots; `common.h` depends on nothing. This kills the star include graph and the circular `hl2h.cpp ↔ menu.cpp` / `esp.cpp ↔ menu.cpp` / `studio.cpp ↔ esp.cpp` edges. ### P3-6 Hook registry and teardown *(S)* A small table of `{name, target, slot, original, replacement, installed}` with `install_all()` / `restore_all()`, used by `hooks.cpp`. Enables the readiness gate (P0-1) to be uniform and gives you an unload path for testing. ### P3-7 Build and launch hygiene *(S)* - `build.sh`: add `-pthread` (code uses `pthread_create`, `std::mutex`, and links `-Wl,--no-undefined`); add object files + dependency tracking for incremental builds; add an optional `-g`/`NDEBUG` switch so crash addresses are symbolisable; fail loudly with a clear message if `-m32` is unavailable; verify `assets/` is next to the built `.so`. - `play.sh`: don't export `LD_PRELOAD` to the 64-bit Steam runtime wrapper (every helper process warns about `ELFCLASS32`); set it only for the 32-bit `hl2.sh` invocation, or re-exec the 32-bit binary directly. Move the hardcoded `+map d2_coast_03` into a variable. - Add `crash` symbolisation instructions (`addr2line -e libhl2hake.so`). --- ## 5. Testing strategy The codebase has no tests, which is why most of these bugs are found by inspection. Most of the P1/P2 defects live in pure functions; extract and test them natively. 1. **`tests/` with a 30-line assert harness**, compiled with the *host* compiler (no `-m32`), no engine deps: - config parser + `SettingDesc` clamping (P0-6, P3-2) - `vector_angles` / `angle_to` / FOV math (P1-8) - clip-rect and `text_in` truncation (P2-3) - bone-cache eviction/validity policy as a pure `BoneCache` class (P1-3) - `proj_first_seen` ring (P1-9) - seed selection / spread scoring (P1-6) - `project_manual` aspect/roll (P2-8) Compile with `-fsanitize=address,undefined`; run in CI-ish `make test`. 2. **Hook-level assertions** (debug builds only): stage must be 3 before a feature runs; `find_modules` only from the setup thread; `seq >= 0`; `g_srv_*` only read after a successful resolve. 3. **In-game smoke matrix** — run after every phase: | Scenario | Covers | |---|---| | `+map` direct load, 10× | P0-1, P0-2 | | main menu → load map → close GUI | P2-5 | | death / respawn 20× | P2-7, P1-2 | | map change A→B→A (HDR and LDR) | P1-2, P2-9, P3-4 | | >64 NPCs on screen | P1-3 | | resolution change + windowed resize | P2-8 | | 60-min session | font/cache/leak growth | | shotgun/pistol/crossbow strings | P1-5, P1-6 | 4. **Regression corpus:** keep `crash`, plus `addr2line` output per fixed item, so a reintroduced crash is recognisable. --- ## 6. Verified NOT a bug — do not fix These appeared in the audit but did not survive verification. Listed so they don't get "fixed" into new bugs. | Claim | Reality | |---|---| | NPC `m_vecVelocity` read is OOB | Offset is `C_BaseEntity::m_vecVelocity` (`0xE0`), valid on all entities. Real issue is that NPCs don't network it → P1-10. | | `CUtlVector` layout is wrong (`+12/+16`) | Correct for 32-bit (`utlvector.h:224-230`). Only range validation missing → P0-4. | | Sky `memcpy` can overflow `dst` | Texture created at PNG dims, RGBA8888, so `pitch >= w*4 >= copy`. Add an assert, no fix. | | `config_load` null deref is P0 | Real but only reachable from a hand-edited config (`config_save` never writes those keys). → P0-6, low. | | `fog_enableskybox` stays on | `world.cpp:345-346` handles the `else` branch; the logic is correct. | | Shotgun pellets need 48 `RandomFloat` draws | `RandomSeed` is called per pellet (`baseentity_shared.cpp:1699`), so 8 draws per pellet is enough. | | Held `IN_ATTACK` breaks semi-autos | Source treats held `IN_ATTACK` as continuous fire; the concrete defect is the missing release edge for the pistol refire reset → folded into P1-5.5. | | `bones_update` serves stale bones today | The missing `valid=false` is real but every in-tree caller checks the return. Fix anyway (P1-3) because it is one line. | | `owned_mask` ORs forever | It resets when the local entity pointer changes; it is a same-map drop bug only. | | `info_model_name` reads OOB | `name[64]` is followed by other header fields, so the 128-byte scan stays inside `studiohdr_t`. Correctness nit only (P0-8). | | `draw_fov_circle` has no GUI toggle | Toggle exists (`gui.cpp:375`). Real bug is the `FEAT_ESP` gate → P1-11. | | `%ebp` is safe because `-fno-omit-frame-pointer` | That flag only applies to this `.so`; callers are Valve binaries → P0-3. | --- ## 7. Sequencing summary | Phase | Items | Effort | Gate to next phase | |---|---|---|---| | P0 | 8 | ~1.5 days | 10× direct-load and menu-load runs with no crash; netvars stable across map change | | P1 | 11 | ~2.5 days | smoke matrix passes; aimbot/vischeck manual acceptance | | P2 | 9 | ~2 days | visual acceptance (alpha, clipping, chams, skybox) + resize test | | P3 | 7 | ~3 days | `tests/` green, `make` incremental, no behaviour diff on smoke matrix | Quick wins if you only have an afternoon: **P0-1, P0-5, P0-6, P0-7, P0-8**, plus **P2-2** (slider 10 px) and **P1-6** (spread gate). Those are all small, local, and remove a crash class or a user-visible bug each. --- ## 8. Implementation log ### Batch 1 — done, builds clean | Item | Status | Notes | |---|---|---| | P0-1 hook readiness gate | done | `init_stage()`/`init_live()` with acquire/release; all hooks pass through until `INIT_LIVE`; setup order is now config+surface → input → client hooks. `INIT_FAILED` on every fatal setup path. | | P0-2 module snapshots | done | `Module` globals replaced by `mod_client()`/`mod_engine()`/`mod_server()` returning an immutable, atomically published snapshot. Retired snapshots are intentionally leaked so a reader's reference never dangles. No lock on the read path. | | P0-3 `%ebp` walk | hardened | New `caller_self_arg()` validates that the walk stays inside the current stack (above `%esp`, <1 MiB, 4-aligned) before dereferencing. Still frame-pointer based; a future pass should drop it in favour of arming from `CreateMove` (option 1 in P0-3). Spread window now reset at every `do_move`. | | P0-4 netvar bounds | done | `module_range(p, n)` added; every `RecvTable`/`RecvProp`/string/array dereference is validated before use. | | P0-5 vtable header | done | `vtable_clone()` preserves `[-2]/[-1]`; used by client, CInput, IVModelRender and IStudioRender hooks. | | P0-6 config `cur` | done | Stray `active=`/`bind=` in scalar/chams sections is ignored instead of dereferenced. | | P0-7 signed `seq` | done | `seq < 0` rejected; modulo uses `unsigned`. | | P0-8 defensive batch | partly | Bone parent-cycle guard, `mprotect` return check and no `PROT_EXEC`, `server.so` `.bss` tail absorbed, studio name bounded to 64. Not done: `build_core_mask` caching. | | P1-6 spread gate | done | Seed hook now arms on `no_spread` only; `engine` toggle controls only the `RandomFloat` zeroing. | | P2-2 slider geometry | done | `slider_track()` is the single source of truth for draw, hit-test and drag; drag clamps `t` to `[0,1]`. | Verification for this batch is compile-level (`build.sh`, `--no-undefined`) plus a diff review. In-game runs still needed for P0-1/P0-2/P0-3 as described in their own sections. ### Batch 2 — done, builds clean | Item | Status | Notes | |---|---|---| | P1-1 dormant entities | done | `entity_is_dormant()` on `IClientNetworkable` slot 8, applied in esp_collect, aimbot candidate scan, the NPC visibility helper, melee, item ESP and projectile ESP. Slot layout is self-tested against the local player (which is never dormant): a wrong slot disables the check with a log line instead of misreporting every entity. | | P1-2 `srv_checked` latch | done | Moved to file scope, exposed via `esp_reset_session()` called on local-entity change; only latches once both client and server model indices are valid. | | P1-3 bone cache (part) | partly | `e->valid = false` now set on the two early returns that skipped it. Still to do: cache sizing/eviction and the `head_position` early-return. | | P1-4 vischeck | done | Glass stepping advances until a probe is clear (doubling, capped, never past the target) instead of a fixed 0.5 units; `VisibilityFilter` ignores entities owned by the target via `m_hOwnerEntity`. | | P1-5 aimbot | part | `maxs < 0` no longer disables the aimbot; prefilter widens the FOV test by the AABB angular radius; lock uses 1.5× hysteresis instead of no limit; the projectile lead point is re-checked for FOV and visibility and falls back to the un-led point. Still to do: `IN_ATTACK` release edge. | | P1-7 owner netvars | done | `m_hOwnerEntity` / `m_hOwner` now resolved (previously always −1). | | P1-8 math/movement | part | `vector_angles` pitch no longer wraps to `[270,360)`; `point_ground` requires `normal.z >= 0.7`; underwater jump+duck no longer skips combat (`goto bhop_done`); melee AABB rejects hugely negative `mins`. Still to do: triggerbot `entindex()`. | | P1-9 fuse table | done | 64-entry ring with stale-entry reset; no longer freezes fuse time after 32 projectiles. | | P1-11 FOV circle | done | Moved out of `esp_draw` into `outer_draw`, gated on aimbot/silent-aim being active plus the toggle, with its own screen-size init. | | P2-1 draw once per frame | part | `outer_draw()` now has a 1 ms gate (panels within a frame are microseconds apart) and `DrawPrintText` only acts as a fallback when no paint happened for 50 ms. Still to do: save/restore ISurface text state. | ### Batch 3 — done, builds clean | Item | Status | Notes | |---|---|---| | P2-3 clipping | done | `line()` is now Liang–Barsky clipped and used by checkbox checkmarks and picker cursors; `outline()` draws four clipped edges instead of outlining the clamped rect; `text_in()` truncates with `...` at `max_w`/clip edge and no longer shifts left. `s_outline` removed. | | P2-4 pointer discipline | done | Clicking outside an open dropdown consumes the click (no activation of the control underneath); `handle_hover` no longer arms hidden sliders while a dropdown, picker, or drag is active. | | P2-5 SDL state | part | `sd_*` pointers now resolve with `RTLD_NEXT` instead of `RTLD_DEFAULT` (which found this file's own wrapper); the wrappers record what the game last requested and `close_gui()` restores that instead of the pre-open snapshot. Not changed: dropping `SDL_MOUSEMOTION` while open. | | P2-6 chams | part | `chams_reset()` now resets `AlphaModulate` (shared materials were permanently left half-faded); `chams_preload()` references each unique material once; prop tint restores white after `DrawModel`/`DrawModelStaticProp`. Not addressed: clearing `ForcedMaterialOverride` during shadow-depth passes (needs a way to read the engine's current override). | | P2-7 fonts | part | `create_gui_font()` creates one handle and adds glyph sets to it instead of leaking one per failed family; `ensure_fonts()` keeps valid handles across level changes and only recreates when `font_valid()` fails. `menu.cpp`'s own font pair still resets on local-entity change. | | P2-8 projection | done | `project_manual()` applies Source's 4:3-reference aspect scaling and includes roll; screen size is refreshed every frame through `esp_update_screen()` in all four draw paths. | | P2-9 skybox | part | Pristine `g_sky_pixels_src` so HDR linearisation no longer bleeds into a later LDR map; `skybox_reload_image()` detaches the regenerator before dropping the handle. Original `ITexture*` refcounts still not taken (needs the ITexture slot verified against the shipped binary). | | P3-4 session reset | done | One `session_reset()` called on local-entity change, listing every per-map cache. | | P3-7 build | done | `-pthread`; incremental objects in `build/{release,debug}` (verified: second run compiles nothing); `DEBUG=1` for `-O0 -g`; explicit 32-bit toolchain check; assets warning; `build/` ignored. | Self-review during this batch caught one bug in my own P1-1 change: the dormant self-test could run before a local player existed (main menu) and latch "disabled" permanently. It now leaves the state untested until a local player is available. ### In-game validation Batch 1–3 items were exercised by the user in a live game and all behaved correctly. Two follow-ups were found and fixed: - speed-graph background flicker — the 1 ms draw gate was not a frame boundary (see the P2-1 follow-up below); - FOV circle never visible — it was gated on `feature_active()`, which requires the bind (default mouse5) to be held; now uses `feature_enabled()`. Both confirmed fixed. ### Batch 4 — done, builds clean | Item | Status | Notes | |---|---|---| | P3-1 overlay layer | done | New `overlay.h` / `overlay.cpp` own the VGUI_Surface030 slot table and every ISurface call. `g_surface` moved there; `menu.cpp`, `gui.cpp` and `esp.cpp` keep their local wrapper names as one-line forwarders, so no call sites changed. Three duplicated slot tables are gone. | | P1-3 bone cache | done | Cache raised to 192 entries (128 targets + room for failed updates); `head_position()` continues past a name-matched bone whose matrix is invalid instead of aborting the search. | | P1-5 fire control | done | `fire_edge_ok()` alternates press/release for semi-auto weapons (pistol, .357, crossbow, shotgun, RPG, gravity gun, crowbar) and holds the button for automatic ones. Applied to silent aim, melee/gravity and triggerbot. | | P1-10 NPC lead | done | New `server_entity_velocity()` reads the listen-server `m_vecVelocity` (already resolved but unused); `projectile_lead_point()` prefers it and falls back to the client field. | | P2-9 `ITexture` refcounts | done | `record_orig()` now takes a reference (slot 10) on every captured texture and `release_sky_originals()` drops them (slot 11) wherever the list is cleared, so a map change can no longer leave `g_sky_orig` holding freed pointers. Slot numbers follow the material-system header order already verified here (SetTextureRegenerator = 12, Download = 13). | | P2-5 motion events | done | `SDL_PollEvent` now keeps `SDL_MOUSEMOTION` events and zeroes only `xrel`/`yrel`, instead of dropping the event entirely (VGUI hover and SDL cursor state stay consistent). | | P1-11 follow-up (circle) | done | The circle was gated on `feature_active()`, which requires the bind to be held; the default aimbot bind is mouse5, so it only appeared while aiming. `feature_enabled()` (toggle only) is now used, and the circle is skipped when there is no local player (main menu). | | P2-1 follow-up (flicker) | done | The 1 ms time gate was the wrong frame boundary: panels more than 1 ms apart in one frame drew twice, stacking the 120-alpha speed-graph background, so it alternated brightness. `overlay_frame_mark()` is now called from `FRAME_RENDER_START` and `overlay_should_draw()` allows exactly one draw per engine frame, with a 10 ms fallback for menu/pause frames that never send the stage. | ### Performance pass The audit's hot paths were removed rather than micro-optimised: | Cost | Before | After | |---|---|---| | NPC classification from the chams hook | `esp_npc_relation()` did `esp_is_npc_index` → entity/model lookup → `server_npc_relation` (edict + `CUtlVector` scan) + `server_npc_state` per model draw, up to 3 passes per entity per frame | 50 ms TTL cache keyed by entity index; a draw now costs a hash lookup | | Friendly/neutral test | `server_npc_relation` + `server_npc_state` (+ turret flags) per call, from the entity scan, aimbot selection and chams | 100 ms TTL cache | | Damage class | `server_npc_takedamage` (edict lookup) + ~20 `strcasestr` per NPC per scan | 250 ms TTL cache | | Model name | two `IVModelInfo` virtual calls per entity per scan (~1000 entities at 100 Hz) | memoised by model index, cleared on map change | | Triggerbot index recovery | walked up to 4096 entity-list slots per fire tick | direct-mapped entity-pointer → index cache filled by the entity scan; the scan remains only as a cold-miss fallback | Item and projectile markers were the other half of the problem: discovery (`items_collect` / `projs_collect`) is throttled to 10 Hz because it walks the entity list, but both draw paths already stored the entity index. They now refresh the origin from the live netvar per frame (`entity_origin()`), so markers, blast-radius rings and the grenade trajectory follow the world at render rate while the expensive scan stays at 10 Hz. The trajectory also uses the networked `m_DmgRadius` instead of a hardcoded 300, and its landing ring respects the "Blast radius" toggle. Caches are cleared by `esp_reset_session()` on map change/respawn. The ESP scan itself is unchanged: projection only happens for the handful of entities that survive the class/life/friendly filters, not for every entity. ### Known remaining work Deliberately deferred, with reasons: - **P1-8 triggerbot `entindex()`** — the current code is correct, only O(N) per fire tick. Replacing it needs a verified `IClientNetworkable::entindex` slot plus a way to get the networkable from a raw entity pointer, or a cached pointer→index map. Not worth the risk for a hot path that fires a few times per second. - **P2-6 shadow-depth `ForcedMaterialOverride`** — requires reading the engine's current override, which VEngineModel016 does not expose. Needs an alternative detection (draw flags in `DrawModelState_t`) validated in-game. - **P3-2 declarative settings table** — large rewrite of `config_save`, `config_load`, `config_reset_defaults` and `build_cards`. It can regress config persistence, so it should follow an in-game pass. - **P3-3 `esp.cpp` split**, **P3-5 header split**, **P3-6 hook registry** — behaviour-neutral moves with a large diff; best done after the current build has been validated in-game so a regression is easy to bisect. Nothing in the two batches has been exercised in-game yet. The smoke matrix in §5 is the next step: direct map load 10× (P0-1), menu → map → GUI close (P2-5), map change A→B→A (P1-2/P2-9), >64 NPCs (P1-3), and a 60-minute session.