From 33f5ed9ef58f26c6b3b52cf605dd4311d658a60d Mon Sep 17 00:00:00 2001 From: salmonik Date: Fri, 6 Nov 2026 16:09:55 +0100 Subject: Fix audit findings: crash safety, correctness, rendering, performance Crash/UB: - gate every hook behind an init-stage flag until config/surface/input are ready - publish module maps as immutable atomic snapshots instead of reallocating shared vectors under unlocked readers - validate the %ebp walk in the RandomSeed/RandomFloat hooks against the stack - bounds-check every RecvTable/RecvProp/string dereference with module_range() - preserve the Itanium vtable header when cloning (client, CInput, model render, studio render) - reject negative usercmd sequence numbers; guard config_load against a null section target; cap the bone parent walk; check mprotect; drop PROT_EXEC on the GOT patch; absorb server.so .bss tail Correctness: - filter dormant entities across ESP, aimbot, triggerbot, melee, items and projectiles (slot self-tested against the local player) - reset the server-health latch per map and only latch on valid model indices - bone cache sized for the 128-target scan; set valid=false on every failure; head_position keeps searching past an invalid bone - vischeck steps past glass by probing until clear, and ignores entities owned by the target (held weapons, bone followers) - aimbot: maxs no longer disables it, AABB-aware FOV prefilter, lock hysteresis, lead point re-checked for FOV and visibility - spread: seed hook arms on the no-spread toggle alone; engine toggle only controls the RandomFloat zeroing - resolve m_hOwnerEntity/m_hOwner; NPC lead velocity from the server entity - vector_angles pitch range, point_ground normal test, underwater jump+duck no longer skips combat, semi-auto press/release edge for synthetic fire - item/projectile markers and blast rings refresh origin per frame while discovery stays at 10 Hz; trajectory uses the real m_DmgRadius and honours the blast-radius toggle Rendering/GUI: - one overlay draw per engine frame (FRAME_RENDER_START boundary), fixing alpha stacking and the speed-graph flicker - clipped lines/outlines and ellipsised text instead of drawing past the content pane or shifting over neighbours - dropdown dismissal consumes the click; hover ignores hidden controls - chams: reset AlphaModulate, reference shared materials once, restore prop tint colour; fonts created once per handle - projection uses Source's 4:3 aspect scaling and roll; screen size refreshed each frame; FOV circle gated on the aim toggles, not the bind Performance: - TTL caches for NPC relation, friendly/neutral and damage class - model names memoised per model index - entity pointer -> index cache for the triggerbot (was a 4096-slot walk/tick) Build: - real Makefile with -MMD dependency tracking, debug/release objects and an always-relink rule; build.sh kept as a thin wrapper --- FIX_PLAN.md | 802 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 802 insertions(+) create mode 100644 FIX_PLAN.md (limited to 'FIX_PLAN.md') diff --git a/FIX_PLAN.md b/FIX_PLAN.md new file mode 100644 index 0000000..e4e149e --- /dev/null +++ b/FIX_PLAN.md @@ -0,0 +1,802 @@ +# 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. -- cgit v1.2.3