# 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. ### Batch 5 — chams shadow passes `IVModelRender::ForcedMaterialOverride` (slot 1) is now hooked as well, purely to track the engine's current forced material. `DrawModelExecute` skips chams when an override is already active, so shadow-depth and reflection passes draw with their own material instead of being substituted with chams and then having the engine's override cleared for every model drawn afterwards. This is the fix for the corruption described in P2-6; it needs an in-game check because it relies on the engine always going through the vtable for that call. ### Batch 6 — hook registry `hooks.h` / `hooks.cpp` own vtable installation: one call clones, bounds-checks every slot, records the originals and installs the clone, with a mutex because installs happen from the setup, game and paint threads. `hooks_restore_all()` puts every interface back, which previously was impossible. Client, CInput, IVModelRender, IStudioRender and ISurface all go through it now. ### Batch 7 — header split `hl2h.h` is now an umbrella over `core.h` (logging, init stage, module snapshots, interfaces, netvars, CUserCmd, engine constants, vector math), `settings.h` (feature ids, feature state, every user setting) and `features.h` (cross-module declarations), plus `overlay.h` and `hooks.h`. Translation units still include `hl2h.h`, so this is a declaration-organisation change only. ### Batch 8 — esp.cpp split `esp.cpp` went from 3665 to ~1000 lines and now only scans and draws. The other halves are `entity_query.cpp` (entity lookups, NPC classification, per-map caches, session reset), `combat.cpp` (aimbot, silent aim, melee, spread, triggerbot), `items.cpp` and `projectiles.cpp`. Cross-unit helpers are declared in `esp_internal.h`; nothing changed behaviourally. ### Batch 9 — settings table `config_table.cpp` holds one descriptor per persisted setting: section, key, type, pointer, default and clamp range. `config_save()`, `config_load()` and `config_reset_defaults()` are generated from it, replacing the hand-written writer, the ~350-line parser and the duplicated `[view]` clamp block. Section and key names are unchanged, so existing config files still load. This needs a save → reload → reset round-trip in-game. ### 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. - **P3-2 GUI binding** — the settings table now drives persistence (save, load, reset); `build_cards()` still binds widgets to globals directly. Folding the GUI metadata into the same table is the only remaining piece, and it is cosmetic: no setting is duplicated in code any more, only in the GUI layout. 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.