summaryrefslogtreecommitdiffstats
path: root/FIX_PLAN.md
diff options
context:
space:
mode:
Diffstat (limited to 'FIX_PLAN.md')
-rw-r--r--FIX_PLAN.md802
1 files changed, 802 insertions, 0 deletions
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 @@
1# hl2h fix plan
2
3Derived from a full audit of `src/` (12,243 lines) plus `build.sh` / `play.sh`.
4Every item below was re-verified against the working tree; claims that did not
5survive verification are listed in [§6](#6-verified-not-a-bug--do-not-fix) so
6nobody wastes time on them.
7
8Ordering: **P0 → P1 → P2 → P3**. Each phase leaves the cheat playable.
9Work inside a phase is independent enough to parallelise; cross-phase
10dependencies are noted.
11
12---
13
14## 0. Verification ground rules
15
16- Line numbers are from the audited tree; re-check after edits.
17- "Fix" means *smallest change that removes the failure mode*, not a rewrite.
18 Rewrites belong to P3.
19- Every P0/P1 item must be reproducible before the fix and re-checked after.
20 For logic fixes, extract the function into `tests/` (see §5) rather than
21 relying on eyeballing a running game.
22- Anything touching hook install/uninstall goes in with a readiness guard
23 (P0-1), otherwise later phases regress the race.
24
25---
26
27## 1. P0 — crash, UB, and startup races
28
29These are the items that produce the existing `crash` log and the highest risk
30of nondeterministic hangs. Do them first, in this order.
31
32### P0-1 Hook readiness gate and init ordering *(M)*
33**Where:** `hl2h.cpp:1185` (`chams_init`), `:1204-1206` (`init_conmsg`,
34`install_hook`, `install_menu`), `menu.cpp:1224` (`config_load`),
35`menu.cpp:1229` (ISurface discovery).
36
37**Problem:** `CHLClient` and `CInput` hooks are installed *before* config load
38and ISurface discovery. The game thread can enter `hkdCreateMove` /
39`hkdInputCreateMove` / `hkdModelExecute` while `setup_thread` is still writing
40feature globals and `g_surface`. Every hook is also installed lazily from
41different threads (surface hooks from `CreateMove`, studio-prop hooks from the
42paint path), so "is init done" is not observable.
43
44**Fix:**
451. Add `std::atomic<int> g_init_stage {0}` (`0` = nothing, `1` = interfaces +
46 netvars, `2` = config + surface + fonts, `3` = hooks live).
472. Move `install_hook()`, `install_input_hook()`, and the seed/uniform patches
48 to *after* `install_menu()` completes.
493. Every hook entry point starts with
50 `if (g_init_stage.load(std::memory_order_acquire) < 3) return call_original(...);`
51 — hooks stay installed, they just pass through until ready.
524. Keep lazy surface/studio installs, but have them check the stage instead of
53 relying on "first `CreateMove` implies ready".
545. On any setup failure, set stage to a terminal `-1` and log once; hooks then
55 pass through forever instead of half-working.
56
57**Verify:** start the game straight into a map (`+map`) and into the main menu,
5810× each, with `HL2H_DEBUG=1`; `hook installed:` lines must now appear after
59`config loaded:` / `surface resolved:`. No crash in `crash`-style backtraces.
60
61---
62
63### P0-2 Module snapshots are published under a lock but read unlocked *(M)*
64**Where:** writer `modules.cpp:78-84`; readers `hl2h.h:47-52`
65(`Module::contains` / `valid` / `.ranges` / `.path`), `netvars.cpp:9`,
66`server.cpp:71,95,147`, `hl2h.cpp:1104-1109`. Re-entry: `server.cpp:147-148`
67calls `find_modules()` from the game thread while `setup_thread` reads
68`g_client`/`g_engine`.
69
70**Problem:** `find_modules()` move-assigns `g_client`/`g_engine`/`g_server`,
71freeing `std::vector<Range>` and `std::string` buffers while another thread
72iterates them. Locking only the writer is worse than no lock — it hides the
73race.
74
75**Fix:**
761. Make `Module` immutable after publish. Keep a
77 `std::shared_ptr<const ModuleSet>` and publish with
78 `std::atomic_store(&g_modules, new_set)` (C++11 `shared_ptr` atomic free
79 functions; C++17 build is fine).
802. Readers do `auto m = std::atomic_load(&g_modules);` once per function and
81 use `m->client` for the whole body. No lock, no torn state.
823. Stop calling `find_modules()` from `server.cpp` / `netvars.cpp` on demand.
83 Resolve once during init; on failure, retry from the setup thread only.
84 If a late retry is genuinely needed, it must publish a new snapshot, never
85 mutate the current one.
86
87**Verify:** run `resolve_server_netvars` with a deliberate 100 ms delay under
88`-fsanitize=thread` on a native (non-`-m32`) build of `modules.cpp` only if
89practical; otherwise add an assertion that `find_modules` is only called from
90the setup thread.
91
92---
93
94### P0-3 `%ebp` frame-pointer dereference in the random hooks *(M)*
95**Where:** `hl2h.cpp:497-501` (`hkdSrvRandomSeed`) and `:606-610`
96(`hkdUniformRandomFloat`). `build.sh` has `-fno-omit-frame-pointer`, but that
97only constrains *this* `.so`; the callers are Valve's `server.so` and
98`libvstdlib.so`.
99
100**Problem:** `frame = *(void **)ebp; self = *(void **)(frame + 8)` assumes a
101standard frame chain and a first-argument-at-`+8` layout in code we do not
102control. A non-frame-pointer caller, tail call, or different convention
103segfaults immediately.
104
105**Fix (in order of preference):**
1061. Drop the `self` derivation entirely. `g_spread_rand_left` only needs to be
107 armed while the *local player* fires; `hkdSrvRandomSeed` already knows the
108 seed window is being requested from the local server entity path
109 (`g_local_server_player` is available via `server_active_weapon`-style
110 lookups). Arm the window on `CreateMove` when `IN_ATTACK` is set and the
111 active weapon has spread, then let the hook only substitute the seed.
1122. If the frame walk stays, hard-validate before both dereferences:
113 `contains_range(ebp, 4) && (ebp & 3) == 0`, same for `frame` and
114 `frame + 8`, and require the resulting `self` to be inside a mapped module
115 or the entity-list heap range. Bail to the original on any failure.
1163. Never leave `g_spread_rand_left` armed across ticks: reset it in
117 `do_move` entry and on `CreateMove` return.
118
119**Verify:** pistols/SMG/AR2/shotgun/crossbow, 20 shots each, log
120`seed in / seed out / fallback` counters; zero fallbacks on a normal map, no
121crash on maps with scripted weapons.
122
123---
124
125### P0-4 Netvar scan bounds validation *(S)*
126**Where:** `netvars.cpp:9-19` (`module_string` checks only the first byte),
127`:33-39` (derefs `table+0`, `table+4`, `table+0xC` before validating `table`),
128`:317` (root pointer from `cc+0xC` unvalidated), plus the nested `ptable` path.
129
130**Fix:** add one helper next to `Module`:
131```cpp
132bool module_range(uintptr_t p, size_t n); // p and p+n-1 inside one range
133```
134Use it for every pointer before the first dereference and for every string
135scan (`module_range(p, outn)`), then keep the existing printable-char check.
136Add an `assert(g_client.contains(table))`-style precondition in
137`collect_netvars` so future callers fail loudly instead of reading garbage.
138
139**Verify:** `resolve_netvars` logs "resolved N/M" on main menu, map load, and
140map change; N/M must not change. No crash when `server.so` is absent.
141
142---
143
144### P0-5 Preserve the Itanium vtable header when hooking *(S)*
145**Where:** `hl2h.cpp:1060-1068`, `cinput.cpp:153-162`,
146`studio.cpp:544-548`, `studio.cpp:584-598`.
147
148**Problem:** all four sites do `new void*[N]; memcpy(new, vt, N*4); ptr = new;`
149so `new[-1]` (typeinfo) and `new[-2]` (offset-to-top) are heap metadata.
150The cheat never calls RTTI on these pointers, so this is currently latent, but
151the engine may, and the fix costs nothing.
152
153**Fix:** allocate `N+2`, set `dst[0]=vt[-2]; dst[1]=vt[-1];` and memcpy the
154slots at `dst+2`; hand out `dst+2`. Add a tiny helper
155`void** clone_vtable(void** vt, size_t slots)` used by all four sites — this
156also removes the duplicated 256/32/49 magic sizes.
157
158**Verify:** hooks still fire (`logd` in each handler); no behaviour change
159expected. Optional: `nm`/`objdump` sanity that the new header words match.
160
161---
162
163### P0-6 `config_load` null deref on malformed config *(S)*
164**Where:** `menu.cpp:347-385` sets `cur = nullptr` for `[view]`,
165`[movement]`, `[chams_*]`; `menu.cpp:643-646` dereferences it unconditionally.
166
167**Reachability:** `config_save` never writes `active`/`bind` there, so this is
168hand-edited/corrupt-file only. Still a one-line crash.
169
170**Fix:** the section loop should carry an explicit "target kind" enum
171(`TARGET_FEATURE`, `TARGET_SCALARS`, `TARGET_CHAMS`, `TARGET_IGNORE`) and the
172key handler should switch on it, not on `cur`. Minimum change:
173`if (!cur) continue;` before the `active`/`bind` branches.
174
175**Verify:** add the malformed cases to `tests/` (§5) — `[view]\nactive=1`,
176`[chams_vm]\nbind=3`, truncated last line, no trailing newline.
177
178---
179
180### P0-7 Signed modulo on `seq` in `cinput.cpp` *(S)*
181**Where:** `cinput.cpp:61`, `:72-73`, `:141`.
182
183**Problem:** `seq % 90` is negative for negative `seq`; `(size_t)` then makes a
184huge index. The `command_number` sanity check happens *after* the pointer is
185formed and dereferenced.
186
187**Fix:** `if (seq < 0) return;` at the top of `usercmd_get` /
188`fix_verified_crc` / `hkdInputCreateMove`, and use
189`((unsigned)seq) % 90u` throughout.
190
191**Verify:** assertion `seq >= 0`; no behaviour change in normal play.
192
193---
194
195### P0-8 Defensive guards with real (if unlikely) triggers *(S, batch)*
196Small, independent, cheap:
197
198| Item | Where | Fix |
199|---|---|---|
200| Unbounded parent walk | `bones.cpp:419-423` | 64-iteration cap, same as the humanoid branch |
201| `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 |
202| `server.so` `.bss` tail not absorbed | `modules.cpp:50-58` | add the `server` branch alongside client/engine |
203| 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 |
204| `bone_nearest_core_parent` / `build_core_mask` recomputed every tick | `bones.cpp:346-362` | recompute only when `hdr`/`count` changes |
205
206**Verify:** map with many NPCs (e.g. `d2_coast_03`), skeleton ESP on, 10 min;
207`bones_update` budget logged once per second must stay flat.
208
209---
210
211## 2. P1 — gameplay and data-correctness bugs
212
213### P1-1 Dormant-entity handling *(M)*
214**Where:** none of `esp_collect` (`esp.cpp:436`), `select_aim_target` (`:916`),
215`melee_aimbot_apply` (`:1375`), `triggerbot_apply` (`:1732`),
216`items_collect` (`:2740`, `:2801`), `projs_collect` (`:3090`),
217`prop_tint_update` (`:1797`) checks `IsDormant()`.
218
219**Fix:** one helper `bool entity_dormant(void* ent)` in the entity-query layer
220(P3 target: `entity_query.cpp`), plus a call at the top of each loop. For
221aimbot/triggerbot/melee this is a correctness fix (stale frozen positions);
222for items/projectiles it is a ghost-marker fix. Do **not** add it to
223`bones_update` alone — gate at collection so downstream code never sees them
224(also removes the "SetupBones on a dormant entity" class of crash).
225
226**Verify:** walk out of an NPC's PVS; box/arrow/skeleton disappears within one
227tick, aimbot stops acquiring it, and no `schema`/studio asserts appear in
228console.
229
230### P1-2 `srv_checked` permanent latch *(S)*
231**Where:** `esp.cpp:433-434`, `:459-464`. Reset block exists at
232`hl2h.cpp:784-793` but does not touch it.
233
234**Fix:** move the two statics into the map-change reset registry (P3-4; a
235temporary `esp_reset_session()` called from `hl2h.cpp:784` is enough now), and
236only latch when `cli_mi > 0 && srv_mi > 0`. Keep re-checking per NPC for the
237first ~30 s after a map load rather than once ever.
238
239**Verify:** load map → immediately look at an NPC before it finishes spawning
240→ health bar must appear once the model index syncs.
241
242### P1-3 Bone cache correctness *(M)*
243**Where:** `bones.cpp:103` (`g_entries[64]`), `:121-125` (`g_next++ % 64`),
244`:322-330` (early returns leave `valid == true`), `:494-497`
245(`head_position` returns on the first name match even if invalid).
246
247**Fix:**
2481. Size the cache for the real upper bound (128 targets) with headroom: 256
249 entries, LRU by stamp, or a `std::unordered_map<int, BoneEntry>` keyed by
250 entity index. Never evict an entry whose `stamp` is the current tick.
2512. Set `e->valid = false` on *every* failure path, including `:322-330`.
2523. `head_position`: `continue` instead of `return` when `hitbox_world` /
253 `bone_pos` fails, so the fallback chain still runs.
2544. Make `bones_update` return the cached entry pointer so callers cannot read
255 a different (evicted) entry than the one that was validated.
256
257**Verify:** map with >64 NPCs (or a `npc_create` stress test); skeleton ESP
258must not flicker; `bones_count` must never return a count for an entity whose
259`SetupBones` failed this tick.
260
261### P1-4 Vischeck: glass stepping and the entity filter *(M)*
262**Where:** `vischeck.cpp:179-214` (step 0.5 past `tr.endpos`), `:189-190`
263(`startsolid` → `false`), `:91-99` (`ShouldHitEntity` ignores only `self` and
264`target`).
265
266**Fix:**
2671. Replace the fixed 0.5 step with "advance until outside the brush, up to
268 N units, then continue", i.e. loop the trace forward while `startsolid` or
269 the hit entity is still the same glass brush, capped at, say, 32 units and
270 never past `point`.
2712. `ShouldHitEntity` must also ignore the target's children: resolve
272 `m_hOwnerEntity` (needs P1-7) and skip anything whose owner is `target`;
273 that covers held weapons and `phys_bone_follower`.
2743. Early-out when `start` is within epsilon of `point` instead of tracing
275 backwards.
276
277**Verify:** shoot through `func_breakable` glass thick and thin, strider and
278gunship hitboxes, and a target holding a weapon — ESP visibility and aimbot
279vischeck must agree with manual LOS.
280
281### P1-5 Aimbot selection and firing *(M)*
282**Where:** `esp.cpp:815-816` (hard `maxs < 0`), `:943-945` (fallback exists),
283`:949-953` (single `approx` prefilter), `:887` (lock with `enforce_fov=false`),
284`:1079-1084` (lead applied after vischeck/FOV), `:1199`, `:1532`, `:1751`
285(unconditional `IN_ATTACK`).
286
287**Fix:**
2881. Delete `g_net.maxs < 0` from the gate at `:815`; rely on the
289 `maxz = ... : 72.0f` fallback.
2902. Prefilter: use a capsule/segment from the entity's `origin.z` to
291 `origin.z + maxz` and test distance to the view ray, or simply widen
292 `max_fov` by the angular radius of the AABB (`atan(maxRadius/dist)`) before
293 rejecting. Then keep the exact per-hitbox test in `try_target`.
2943. Lock: keep the lock but re-apply the FOV test with hysteresis (e.g. allow
295 up to `max_fov * 1.5` before dropping), and key the lock on
296 `(entindex, serial)` from `CBaseHandle`/`entindex` so slot reuse cannot
297 transfer it.
2984. Lead: compute `lead` first, then run vischeck and FOV on `lead`. If the
299 lead point fails, fall back to the un-led point rather than firing at a
300 wall.
3015. Fire control: track the tick of the last synthetic `IN_ATTACK` and clear
302 the button for one tick after each shot (a small state machine per
303 weapon), or drive `IN_ATTACK` from `m_afButtonPressed` semantics so
304 semi-autos see a release edge.
305
306**Verify:** per weapon class (pistol, SMG, AR2, shotgun, crossbow, RPG) run
30710-shot strings at short/medium/long range, log chosen target id, hitbox, and
308whether FOV/vischeck passed on the *led* point.
309
310### P1-6 Spread control gate *(S)*
311**Where:** `hl2h.cpp:505-507` and `:601-602` require
312`spread_no_spread_enabled() && spread_engine_enabled()`; `esp.cpp:1659` only
313requires `no_spread`.
314
315**Problem:** turning off "Engine spread override" disables the seed hook
316entirely, so "seed selection only" does nothing (documented at
317`hl2h.cpp:474`).
318
319**Fix:** decouple the two gates: the seed-substitution hook is armed by
320`spread_no_spread_enabled()`; `spread_engine_enabled()` only controls the
321`RandomFloat`→0 override. Update the GUI tooltip text if the semantics change.
322
323**Verify:** with engine override off, 20 shotgun blasts: pellet grouping must
324be tight and repeatable.
325
326### P1-7 Resolve `m_hOwnerEntity` / `m_hOwner` *(S)*
327**Where:** `netvars.cpp` has no branch for either; `g_net.owner` /
328`g_net.weapon_owner` stay `-1` (`hl2h.h:99-100`) yet are read at
329`esp.cpp:2253-2259`, `:2272-2275`.
330
331**Fix:** add both name matches in the netvar walk (with the P0-4 range checks),
332then use them for (a) the vischeck filter in P1-4, (b) weapon ownership in
333`items_collect`.
334
335**Verify:** item ESP must stop marking freshly dropped weapons as owned;
336vischeck must stop being blocked by held weapons.
337
338### P1-8 Math and movement correctness *(S)*
339| Item | Where | Fix |
340|---|---|---|
341| `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` |
342| 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()` |
343| `point_ground` ignores surface normal | `vischeck.cpp:273` | require `tr.normal.z >= 0.7f` like `ground_distance` (`:291`) |
344| `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 |
345| melee AABB has no lower bounds | `esp.cpp:1420-1431` | reject `minx/miny/minz` outside `±512` |
346| triggerbot O(4096) index scan | `esp.cpp:1729-1758` | use `IClientNetworkable::entindex()` on the hit entity |
347
348**Verify:** underwater jump+duck still bhops and still shoots; edge-jump at a
349steep-slope ledge; aimbot aims correctly when looking up (pitch sign).
350
351### P1-9 Cache/table lifetime bugs *(S)*
352**Where:** `esp.cpp:3060-3073` (`proj_first_seen` 32 fixed slots, no
353eviction); `esp.cpp:2719-2774` (`owned_mask` OR'd within a map).
354
355**Fix:** replace `proj_first_seen` with a 64-entry ring keyed by
356`(entindex, serial)` that overwrites the oldest entry; recompute `owned_mask`
357from `server_player_weapon(local_idx, slot)` on every scan when the server
358interface is available (keep the OR as fallback only).
359
360**Verify:** throw >40 grenades on one map; fuse timers must stay correct. Drop
361and re-pick a weapon; item ESP must reclassify it.
362
363### P1-10 Projectile lead source *(S)*
364**Where:** `esp.cpp:1045-1048` reads `C_BaseEntity::m_vecVelocity` on NPC
365targets, but Source only networks that field for the player
366(`baseentity.cpp:6665`), so NPC lead velocity is stale/zero.
367`g_srv_velocity` is resolved at `server.cpp:161` and never used.
368
369**Fix:** for NPC targets, read velocity through the server entity path
370(`g_srv_velocity`) and fall back to zero lead on failure. Grenades
371(`esp.cpp:3185-3187`) are fine — they do network velocity.
372
373**Verify:** crossbow/RPG lead on a moving combine at range; the led point
374should lead the NPC's actual movement.
375
376### P1-11 Visual toggles gated by the wrong feature *(S)*
377- `draw_fov_circle` is inside `esp_draw` (`esp.cpp:2084`, gated by
378 `FEAT_ESP` at `menu.cpp:1083`) so it shows with aimbot off and hides with
379 ESP off. Move the call to a neutral draw path or gate it on
380 `aimbot_active || silentaim_active`.
381- `draw_transitions` (`esp.cpp:2092`) has no setting at all. Add a toggle and
382 default it off (its `transitions_scan` walks server entities every frame).
383- `draw_fov_circle` rejects `fov >= 89` (`esp.cpp:606`) while the slider
384 allows 180 (`gui.cpp:371`). Clamp the slider to the drawable range, or
385 render the cone as a screen-space wedge instead of projecting axis points.
386
387**Verify:** toggle ESP off with aimbot on → circle stays; toggle aimbot off
388with ESP on → circle gone; transitions toggle actually stops the wireframes.
389
390---
391
392## 3. P2 — rendering and GUI correctness
393
394### P2-1 Draw exactly once per frame *(M)*
395**Where:** `hl2h.cpp:1008-1032`; guard is only `g_in_draw` (`:1003`), which
396prevents nesting, not repetition. Both `hkdDrawPrintText` (`:1017`) and
397`hkdPaintTraverseEx` (`:1027-1031`) call `outer_draw()`.
398
399**Fix:** pick one owner. Preferred: `PaintTraverseEx` filtered to
400`MatSystemTopPanel` (or the engine's root panel), and use `DrawPrintText`
401only as a fallback if no paint happened in the current frame
402(`last_paint_frame != g_frame_count`). Add a per-frame id so the guard is
403"already drew this frame", not "currently drawing".
404
405**Secondary (independent):** save and restore ISurface text state around
406`outer_draw()` — `m_hCurrentFont` (`this+0x290`), `drawColor` (`+0x24`),
407`textColor` (`+0x28`), `m_nTextX/Y` (`+0x2c/0x30`) — because
408`PushMakeCurrent`/`PopMakeCurrent` only unwinds the clip stack
409(`ISURFACE_STATUS.md:64-67`).
410
411**Verify:** alpha-255 world tint and a 120-alpha ESP box must look identical
412whether ESP is drawn once or with the old code; instrument a frame counter and
413assert exactly one `outer_draw` per frame.
414
415### P2-2 Single source of truth for widget geometry *(S now, folds into P3-2)*
416**Where:** `gui.cpp:1347-1351` (`cr.x` includes `+S(10)`),
417`:1948` and `:1627` (track from `cr.x`), `:1854-1855` (drag range omits the
418`+S(10)` and is 10 px wider at the right edge).
419
420**Immediate fix:** compute the track rect once in a helper
421`slider_track(const Rect& cr)` and call it from `draw_slider`,
422`handle_mouse_click`, and the drag handler. Add the missing `+S(10)` in the
423drag range as a one-line stopgap if the helper is deferred.
424
425**Verify:** click a slider knob at the far left/right, drag across; value must
426track the cursor 1:1 and not jump on mouse-down.
427
428### P2-3 Clipping correctness *(S)*
429| Bug | Where | Fix |
430|---|---|---|
431| `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`) |
432| `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 |
433| `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 |
434
435**Verify:** scroll a card halfway past the content top/bottom — no border line
436through the card, no checkmark floating in the header, long dropdown labels
437truncate instead of overlapping the label column.
438
439### P2-4 Pointer/click discipline in the GUI *(S)*
440**Where:** `gui.cpp:1501-1522` (dropdown dismissal falls through),
441`:1717-1772` (`handle_hover` ignores `g_dropdown_ctrl`).
442
443**Fix:** a single `consume_click()` result from the dropdown block; when it
444closes, return immediately. `handle_hover` early-outs while a dropdown, colour
445picker drag, or slider drag is active. Also close pickers/dropdowns on
446outside-click consistently (currently pickers need another colour control).
447
448**Verify:** open a dropdown over a toggle row and click the toggle; the
449dropdown must close and the toggle must not flip.
450
451### P2-5 SDL interposition state tracking *(M)*
452**Where:** `gui.cpp:140-141` (`dlsym(RTLD_DEFAULT, ...)` resolves the cheat's
453own exported wrapper), `:2415-2434` (requests dropped while open),
454`:1382-1385` (restores the pre-open snapshot), `:1410-1412` (snapshot).
455
456**Fix:**
4571. Resolve real symbols with `dlsym(RTLD_NEXT, ...)` in both wrappers (they
458 already do internally at `:2417`/`:2430`; the public `sd_*` pointers must
459 match).
4602. Track *last requested* state while open: in the wrappers, record
461 `g_game_wants_cursor` / `g_game_wants_relative` before forcing the GUI
462 state, and restore those on close instead of the pre-open snapshot.
4633. Don't drop all `SDL_MOUSEMOTION`; drop only the relative-motion deltas the
464 game would consume, or key off `g_open` + "not over a GUI window".
4654. Keep the `MOUSEWHEEL` watch but honour `direction == FLIPPED`
466 (`SDL_MouseWheelEvent` at `+24`).
467
468**Verify:** open GUI on the main menu → load a map → close GUI; mouse look must
469work and the cursor must hide. Same sequence with alt-tab in the middle.
470
471### P2-6 Chams material state *(M)*
472**Where:** `studio.cpp:267` (AlphaModulate set), `:282-291` (`chams_reset`
473never resets it), `:350-384` (forced override cleared for every pass,
474including shadow depth), `:494-510` (same `IMaterial*` refcounted 2–4×),
475`:122-131` (`hook_draw_model` never restores the previous modulation).
476
477**Fix:** save the previous material state (colour modulation, `$color`,
478`$alpha`, `AlphaModulate`) and restore it in `chams_reset`; dedupe material
479names in `chams_preload` (one `IncrementReferenceCount` per unique material);
480skip the override when the current pass is a shadow-depth draw (compare the
481engine's existing forced override, or check the draw flags before clearing);
482restore `SetColorModulation` after `hook_draw_model`.
483
484**Verify:** chams on/off repeatedly with a flashlight/shadow-casting map;
485shadows must not disappear; translucent chams must not stay semi-transparent
486after toggling off; static prop tint must not bleed onto other models.
487
488### P2-7 Font lifecycle *(S)*
489**Where:** `gui.cpp:687-703` (create inside the family loop),
490`:704-713`/`:725-727` (reset on level/scale change), `menu.cpp:1107-1131`
491(same pattern).
492
493**Fix:** create once per `(scale, DPI, font file)` and keep the handles; try
494families by `AddGlyphSetToFont` on one handle where possible. Level changes
495must not reset fonts at all (VGUI fonts are global). On scale change,
496recreate once and log; accept the bounded leak because `ISurface` has no
497`DestroyFont`.
498
499**Verify:** die/respawn 20× and change UI scale 5×; log the `HFont` counter —
500it must not grow per level change.
501
502### P2-8 Projection and resolution *(M)*
503**Where:** `esp.cpp:57-79` (`project_manual` ignores the
504aspect/(4/3) scaling Source applies at `view.cpp:1075-1083`, and ignores
505roll), `:2078`, `:2925`, `:3301` (screen size cached once).
506
507**Fix:**
5081. Prefer `IVDebugOverlay::ScreenPosition` everywhere; keep manual projection
509 only as a fallback, and in the fallback apply
510 `tanHalfH *= aspect / (4.0f/3.0f)` and include the roll term from
511 `g_cam_view.z`.
5122. Refresh `g_scr_w`/`g_scr_h` every frame (or on `GetScreenSize` change) —
513 mirror what `gui.cpp:928-930` already does.
5143. Don't mix `world_to_screen` and `project_manual` across one segment
515 (`esp.cpp:2006-2009`); pick one per segment.
516
517**Verify:** 16:9, 16:10, 21:9, and windowed resize mid-session: blast rings and
518trajectory arcs must stay glued to the world; no kink at the near plane.
519
520### P2-9 Skybox lifetime *(M)*
521**Where:** `world.cpp:752-761` (in-place HDR linearisation), `:953-959`
522(`skybox_invalidate` keeps texture + pixels), `:699-705`/`:720-729`
523(originals stored without refcount), `:961-968` (`g_sky_tex = nullptr` leaks
524the procedural texture).
525
526**Fix:** keep a pristine copy (`g_sky_pixels_src`) and rebuild the working
527buffer on each apply; `AddRef` originals when recorded and release on
528invalidate; detach the regenerator and `DecrementReferenceCount` the old
529procedural texture on reload. Either implement the documented
530`SetUndefined()`/`Refresh()` path or update `CONTEXT.md:633-635` — right now
531the docs describe code that does not exist.
532
533**Verify:** HDR map → LDR map → HDR map; sky brightness must reset each time.
534No material-system assert on map change with a custom skybox.
535
536---
537
538## 4. P3 — architecture
539
540Do these only after P0/P1 are green. Each is behaviour-preserving and can land
541independently. Order is by bug-surface reduction per unit of risk.
542
543### P3-1 Overlay layer: one ISurface wrapper *(M)*
544`menu.cpp` (`SURF_*`/`surf_*`), `esp.cpp` (`SurfSlot`/`surf_*`), `gui.cpp`
545(`GS_*`/`s_*`) each hardcode the same slots. Introduce `overlay.h/.cpp` with
546the slot table, `surface_*` primitives, text drawing, clip handling, and the
547`outer_draw()` dispatcher. Everything else calls it. This also gives P2-3/P2-1
548one place to fix.
549
550### P3-2 Declarative settings table *(M)*
551Replace the current six-place edit (header extern, definition,
552`config_save`, `config_load`, `config_reset_defaults`, `build_cards`) with one
553table:
554```cpp
555struct SettingDesc { const char* section; const char* key; Type type;
556 void* ptr; double def, min, max; const char* label; };
557```
558Generate save/load/reset/clamp from it; `build_cards` consumes the same
559descriptors. Eliminates P0-6 and the duplicated clamp bounds, and makes new
560options cheap.
561
562### P3-3 Split `esp.cpp` *(L, mechanical)*
563Target files, keeping current call graph: `entity_query.cpp` (class names, NPC
564relation, viewmodel/owned-weapon helpers — also the home for `entity_dormant`
565from P1-1), `combat.cpp` (aimbot/silent/triggerbot/melee/spread search),
566`esp.cpp` (boxes/skeleton/arrows/speed graph/transitions), `items.cpp`,
567`projectiles.cpp`. No behaviour change; move code verbatim, then fix includes.
568
569### P3-4 Session-reset registry *(S)*
570One `session_reset()` called from the map-change path (`hl2h.cpp:784-793`
571already has the hook point) that clears every file-scope cache:
572`bones_clear`, `srv_checked`, `owned_mask`, `proj_first_seen`, bone ring
573buffer, `g_scr_*`, spread state, aim/silent locks, item/proj arrays,
574`g_map_last/prev`. Add a debug counter so a forgotten cache is visible.
575
576### P3-5 Header split and dependency direction *(M)*
577Split `hl2h.h` into `common.h` / `core.h` / `settings.h` / `interfaces.h` /
578`netvars.h` / `server.h` / `entity_query.h` / `bones.h` / `trace.h` /
579`overlay.h` / `ui.h`. Enforce: features never include `hooks.h`/`init.h`;
580`overlay.h` is the only place that knows ISurface slots; `common.h` depends on
581nothing. This kills the star include graph and the circular
582`hl2h.cpp ↔ menu.cpp` / `esp.cpp ↔ menu.cpp` / `studio.cpp ↔ esp.cpp` edges.
583
584### P3-6 Hook registry and teardown *(S)*
585A small table of `{name, target, slot, original, replacement, installed}` with
586`install_all()` / `restore_all()`, used by `hooks.cpp`. Enables the readiness
587gate (P0-1) to be uniform and gives you an unload path for testing.
588
589### P3-7 Build and launch hygiene *(S)*
590- `build.sh`: add `-pthread` (code uses `pthread_create`,
591 `std::mutex`, and links `-Wl,--no-undefined`); add object files +
592 dependency tracking for incremental builds; add an optional `-g`/`NDEBUG`
593 switch so crash addresses are symbolisable; fail loudly with a clear message
594 if `-m32` is unavailable; verify `assets/` is next to the built `.so`.
595- `play.sh`: don't export `LD_PRELOAD` to the 64-bit Steam runtime wrapper
596 (every helper process warns about `ELFCLASS32`); set it only for the 32-bit
597 `hl2.sh` invocation, or re-exec the 32-bit binary directly. Move the
598 hardcoded `+map d2_coast_03` into a variable.
599- Add `crash` symbolisation instructions (`addr2line -e libhl2hake.so`).
600
601---
602
603## 5. Testing strategy
604
605The codebase has no tests, which is why most of these bugs are found by
606inspection. Most of the P1/P2 defects live in pure functions; extract and test
607them natively.
608
6091. **`tests/` with a 30-line assert harness**, compiled with the *host*
610 compiler (no `-m32`), no engine deps:
611 - config parser + `SettingDesc` clamping (P0-6, P3-2)
612 - `vector_angles` / `angle_to` / FOV math (P1-8)
613 - clip-rect and `text_in` truncation (P2-3)
614 - bone-cache eviction/validity policy as a pure `BoneCache` class
615 (P1-3)
616 - `proj_first_seen` ring (P1-9)
617 - seed selection / spread scoring (P1-6)
618 - `project_manual` aspect/roll (P2-8)
619 Compile with `-fsanitize=address,undefined`; run in CI-ish `make test`.
6202. **Hook-level assertions** (debug builds only): stage must be 3 before a
621 feature runs; `find_modules` only from the setup thread; `seq >= 0`;
622 `g_srv_*` only read after a successful resolve.
6233. **In-game smoke matrix** — run after every phase:
624 | Scenario | Covers |
625 |---|---|
626 | `+map` direct load, 10× | P0-1, P0-2 |
627 | main menu → load map → close GUI | P2-5 |
628 | death / respawn 20× | P2-7, P1-2 |
629 | map change A→B→A (HDR and LDR) | P1-2, P2-9, P3-4 |
630 | >64 NPCs on screen | P1-3 |
631 | resolution change + windowed resize | P2-8 |
632 | 60-min session | font/cache/leak growth |
633 | shotgun/pistol/crossbow strings | P1-5, P1-6 |
6344. **Regression corpus:** keep `crash`, plus `addr2line` output per fixed
635 item, so a reintroduced crash is recognisable.
636
637---
638
639## 6. Verified NOT a bug — do not fix
640
641These appeared in the audit but did not survive verification. Listed so they
642don't get "fixed" into new bugs.
643
644| Claim | Reality |
645|---|---|
646| 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. |
647| `CUtlVector` layout is wrong (`+12/+16`) | Correct for 32-bit (`utlvector.h:224-230`). Only range validation missing → P0-4. |
648| Sky `memcpy` can overflow `dst` | Texture created at PNG dims, RGBA8888, so `pitch >= w*4 >= copy`. Add an assert, no fix. |
649| `config_load` null deref is P0 | Real but only reachable from a hand-edited config (`config_save` never writes those keys). → P0-6, low. |
650| `fog_enableskybox` stays on | `world.cpp:345-346` handles the `else` branch; the logic is correct. |
651| Shotgun pellets need 48 `RandomFloat` draws | `RandomSeed` is called per pellet (`baseentity_shared.cpp:1699`), so 8 draws per pellet is enough. |
652| 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. |
653| `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. |
654| `owned_mask` ORs forever | It resets when the local entity pointer changes; it is a same-map drop bug only. |
655| `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). |
656| `draw_fov_circle` has no GUI toggle | Toggle exists (`gui.cpp:375`). Real bug is the `FEAT_ESP` gate → P1-11. |
657| `%ebp` is safe because `-fno-omit-frame-pointer` | That flag only applies to this `.so`; callers are Valve binaries → P0-3. |
658
659---
660
661## 7. Sequencing summary
662
663| Phase | Items | Effort | Gate to next phase |
664|---|---|---|---|
665| P0 | 8 | ~1.5 days | 10× direct-load and menu-load runs with no crash; netvars stable across map change |
666| P1 | 11 | ~2.5 days | smoke matrix passes; aimbot/vischeck manual acceptance |
667| P2 | 9 | ~2 days | visual acceptance (alpha, clipping, chams, skybox) + resize test |
668| P3 | 7 | ~3 days | `tests/` green, `make` incremental, no behaviour diff on smoke matrix |
669
670Quick wins if you only have an afternoon: **P0-1, P0-5, P0-6, P0-7, P0-8**,
671plus **P2-2** (slider 10 px) and **P1-6** (spread gate). Those are all
672small, local, and remove a crash class or a user-visible bug each.
673
674---
675
676## 8. Implementation log
677
678### Batch 1 — done, builds clean
679
680| Item | Status | Notes |
681|---|---|---|
682| 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. |
683| 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. |
684| 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`. |
685| P0-4 netvar bounds | done | `module_range(p, n)` added; every `RecvTable`/`RecvProp`/string/array dereference is validated before use. |
686| P0-5 vtable header | done | `vtable_clone()` preserves `[-2]/[-1]`; used by client, CInput, IVModelRender and IStudioRender hooks. |
687| P0-6 config `cur` | done | Stray `active=`/`bind=` in scalar/chams sections is ignored instead of dereferenced. |
688| P0-7 signed `seq` | done | `seq < 0` rejected; modulo uses `unsigned`. |
689| 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. |
690| P1-6 spread gate | done | Seed hook now arms on `no_spread` only; `engine` toggle controls only the `RandomFloat` zeroing. |
691| 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]`. |
692
693Verification for this batch is compile-level (`build.sh`, `--no-undefined`)
694plus a diff review. In-game runs still needed for P0-1/P0-2/P0-3 as described
695in their own sections.
696
697### Batch 2 — done, builds clean
698
699| Item | Status | Notes |
700|---|---|---|
701| 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. |
702| 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. |
703| 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. |
704| 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`. |
705| 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. |
706| P1-7 owner netvars | done | `m_hOwnerEntity` / `m_hOwner` now resolved (previously always −1). |
707| 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()`. |
708| P1-9 fuse table | done | 64-entry ring with stale-entry reset; no longer freezes fuse time after 32 projectiles. |
709| 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. |
710| 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. |
711
712### Batch 3 — done, builds clean
713
714| Item | Status | Notes |
715|---|---|---|
716| 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. |
717| 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. |
718| 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. |
719| 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). |
720| 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. |
721| 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. |
722| 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). |
723| P3-4 session reset | done | One `session_reset()` called on local-entity change, listing every per-map cache. |
724| 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. |
725
726Self-review during this batch caught one bug in my own P1-1 change: the
727dormant self-test could run before a local player existed (main menu) and latch
728"disabled" permanently. It now leaves the state untested until a local player
729is available.
730
731### In-game validation
732
733Batch 1–3 items were exercised by the user in a live game and all behaved
734correctly. Two follow-ups were found and fixed:
735
736- speed-graph background flicker — the 1 ms draw gate was not a frame boundary
737 (see the P2-1 follow-up below);
738- FOV circle never visible — it was gated on `feature_active()`, which requires
739 the bind (default mouse5) to be held; now uses `feature_enabled()`.
740
741Both confirmed fixed.
742
743### Batch 4 — done, builds clean
744
745| Item | Status | Notes |
746|---|---|---|
747| 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. |
748| 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. |
749| 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. |
750| 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. |
751| 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). |
752| 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). |
753| 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). |
754| 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. |
755
756### Performance pass
757
758The audit's hot paths were removed rather than micro-optimised:
759
760| Cost | Before | After |
761|---|---|---|
762| 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 |
763| 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 |
764| Damage class | `server_npc_takedamage` (edict lookup) + ~20 `strcasestr` per NPC per scan | 250 ms TTL cache |
765| Model name | two `IVModelInfo` virtual calls per entity per scan (~1000 entities at 100 Hz) | memoised by model index, cleared on map change |
766| 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 |
767
768Item and projectile markers were the other half of the problem: discovery
769(`items_collect` / `projs_collect`) is throttled to 10 Hz because it walks the
770entity list, but both draw paths already stored the entity index. They now
771refresh the origin from the live netvar per frame (`entity_origin()`), so
772markers, blast-radius rings and the grenade trajectory follow the world at
773render rate while the expensive scan stays at 10 Hz. The trajectory also uses
774the networked `m_DmgRadius` instead of a hardcoded 300, and its landing ring
775respects the "Blast radius" toggle.
776
777Caches are cleared by `esp_reset_session()` on map change/respawn. The ESP scan
778itself is unchanged: projection only happens for the handful of entities that
779survive the class/life/friendly filters, not for every entity.
780
781### Known remaining work
782
783Deliberately deferred, with reasons:
784
785- **P1-8 triggerbot `entindex()`** — the current code is correct, only O(N)
786 per fire tick. Replacing it needs a verified `IClientNetworkable::entindex`
787 slot plus a way to get the networkable from a raw entity pointer, or a
788 cached pointer→index map. Not worth the risk for a hot path that fires a few
789 times per second.
790- **P2-6 shadow-depth `ForcedMaterialOverride`** — requires reading the
791 engine's current override, which VEngineModel016 does not expose. Needs an
792 alternative detection (draw flags in `DrawModelState_t`) validated in-game.
793- **P3-2 declarative settings table** — large rewrite of `config_save`,
794 `config_load`, `config_reset_defaults` and `build_cards`. It can regress
795 config persistence, so it should follow an in-game pass.
796- **P3-3 `esp.cpp` split**, **P3-5 header split**, **P3-6 hook registry** —
797 behaviour-neutral moves with a large diff; best done after the current build
798 has been validated in-game so a regression is easy to bisect.
799
800Nothing in the two batches has been exercised in-game yet. The smoke matrix in
801§5 is the next step: direct map load 10× (P0-1), menu → map → GUI close (P2-5),
802map change A→B→A (P1-2/P2-9), >64 NPCs (P1-3), and a 60-minute session.