summaryrefslogtreecommitdiffstats
path: root/FIX_PLAN.md
blob: 5b0f59bb3bcfe3d855e6fd54d31fedecc0a23f75 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
# 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<int> 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<Range>` 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<const ModuleSet>` 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<int, BoneEntry>` keyed by
   entity index. Never evict an entry whose `stamp` is the current tick.
2. Set `e->valid = false` on *every* failure path, including `:322-330`.
3. `head_position`: `continue` instead of `return` when `hitbox_world` /
   `bone_pos` fails, so the fallback chain still runs.
4. Make `bones_update` return the cached entry pointer so callers cannot read
   a different (evicted) entry than the one that was validated.

**Verify:** map with >64 NPCs (or a `npc_create` stress test); skeleton ESP
must not flicker; `bones_count` must never return a count for an entity whose
`SetupBones` failed this tick.

### P1-4 Vischeck: glass stepping and the entity filter *(M)*
**Where:** `vischeck.cpp:179-214` (step 0.5 past `tr.endpos`), `:189-190`
(`startsolid` → `false`), `:91-99` (`ShouldHitEntity` ignores only `self` and
`target`).

**Fix:**
1. Replace the fixed 0.5 step with "advance until outside the brush, up to
   N units, then continue", i.e. loop the trace forward while `startsolid` or
   the hit entity is still the same glass brush, capped at, say, 32 units and
   never past `point`.
2. `ShouldHitEntity` must also ignore the target's children: resolve
   `m_hOwnerEntity` (needs P1-7) and skip anything whose owner is `target`;
   that covers held weapons and `phys_bone_follower`.
3. Early-out when `start` is within epsilon of `point` instead of tracing
   backwards.

**Verify:** shoot through `func_breakable` glass thick and thin, strider and
gunship hitboxes, and a target holding a weapon — ESP visibility and aimbot
vischeck must agree with manual LOS.

### P1-5 Aimbot selection and firing *(M)*
**Where:** `esp.cpp:815-816` (hard `maxs < 0`), `:943-945` (fallback exists),
`:949-953` (single `approx` prefilter), `:887` (lock with `enforce_fov=false`),
`:1079-1084` (lead applied after vischeck/FOV), `:1199`, `:1532`, `:1751`
(unconditional `IN_ATTACK`).

**Fix:**
1. Delete `g_net.maxs < 0` from the gate at `:815`; rely on the
   `maxz = ... : 72.0f` fallback.
2. Prefilter: use a capsule/segment from the entity's `origin.z` to
   `origin.z + maxz` and test distance to the view ray, or simply widen
   `max_fov` by the angular radius of the AABB (`atan(maxRadius/dist)`) before
   rejecting. Then keep the exact per-hitbox test in `try_target`.
3. Lock: keep the lock but re-apply the FOV test with hysteresis (e.g. allow
   up to `max_fov * 1.5` before dropping), and key the lock on
   `(entindex, serial)` from `CBaseHandle`/`entindex` so slot reuse cannot
   transfer it.
4. Lead: compute `lead` first, then run vischeck and FOV on `lead`. If the
   lead point fails, fall back to the un-led point rather than firing at a
   wall.
5. Fire control: track the tick of the last synthetic `IN_ATTACK` and clear
   the button for one tick after each shot (a small state machine per
   weapon), or drive `IN_ATTACK` from `m_afButtonPressed` semantics so
   semi-autos see a release edge.

**Verify:** per weapon class (pistol, SMG, AR2, shotgun, crossbow, RPG) run
10-shot strings at short/medium/long range, log chosen target id, hitbox, and
whether FOV/vischeck passed on the *led* point.

### P1-6 Spread control gate *(S)*
**Where:** `hl2h.cpp:505-507` and `:601-602` require
`spread_no_spread_enabled() && spread_engine_enabled()`; `esp.cpp:1659` only
requires `no_spread`.

**Problem:** turning off "Engine spread override" disables the seed hook
entirely, so "seed selection only" does nothing (documented at
`hl2h.cpp:474`).

**Fix:** decouple the two gates: the seed-substitution hook is armed by
`spread_no_spread_enabled()`; `spread_engine_enabled()` only controls the
`RandomFloat`→0 override. Update the GUI tooltip text if the semantics change.

**Verify:** with engine override off, 20 shotgun blasts: pellet grouping must
be tight and repeatable.

### P1-7 Resolve `m_hOwnerEntity` / `m_hOwner` *(S)*
**Where:** `netvars.cpp` has no branch for either; `g_net.owner` /
`g_net.weapon_owner` stay `-1` (`hl2h.h:99-100`) yet are read at
`esp.cpp:2253-2259`, `:2272-2275`.

**Fix:** add both name matches in the netvar walk (with the P0-4 range checks),
then use them for (a) the vischeck filter in P1-4, (b) weapon ownership in
`items_collect`.

**Verify:** item ESP must stop marking freshly dropped weapons as owned;
vischeck must stop being blocked by held weapons.

### P1-8 Math and movement correctness *(S)*
| Item | Where | Fix |
|---|---|---|
| `vector_angles` pitch wraps to `[0,360)` | `hl2h.cpp:90-94` | return `[-90,90]`; normalise yaw to `[-180,180]`; delete the `.x = 0` workaround at `:865` |
| Early `return` skips all features underwater | `hl2h.cpp:855-859` | set the underwater flag and `goto`/`break` past only the bhop section; never skip `note_modified()` |
| `point_ground` ignores surface normal | `vischeck.cpp:273` | require `tr.normal.z >= 0.7f` like `ground_distance` (`:291`) |
| `jb_force_next_distance` casts away `const`, cached `vz` goes stale | `hl2h.cpp:286-304`, `:334`, `:459` | drop the cached `vz`, re-read `vel[2]` after the call, or return the new value |
| melee AABB has no lower bounds | `esp.cpp:1420-1431` | reject `minx/miny/minz` outside `±512` |
| triggerbot O(4096) index scan | `esp.cpp:1729-1758` | use `IClientNetworkable::entindex()` on the hit entity |

**Verify:** underwater jump+duck still bhops and still shoots; edge-jump at a
steep-slope ledge; aimbot aims correctly when looking up (pitch sign).

### P1-9 Cache/table lifetime bugs *(S)*
**Where:** `esp.cpp:3060-3073` (`proj_first_seen` 32 fixed slots, no
eviction); `esp.cpp:2719-2774` (`owned_mask` OR'd within a map).

**Fix:** replace `proj_first_seen` with a 64-entry ring keyed by
`(entindex, serial)` that overwrites the oldest entry; recompute `owned_mask`
from `server_player_weapon(local_idx, slot)` on every scan when the server
interface is available (keep the OR as fallback only).

**Verify:** throw >40 grenades on one map; fuse timers must stay correct. Drop
and re-pick a weapon; item ESP must reclassify it.

### P1-10 Projectile lead source *(S)*
**Where:** `esp.cpp:1045-1048` reads `C_BaseEntity::m_vecVelocity` on NPC
targets, but Source only networks that field for the player
(`baseentity.cpp:6665`), so NPC lead velocity is stale/zero.
`g_srv_velocity` is resolved at `server.cpp:161` and never used.

**Fix:** for NPC targets, read velocity through the server entity path
(`g_srv_velocity`) and fall back to zero lead on failure. Grenades
(`esp.cpp:3185-3187`) are fine — they do network velocity.

**Verify:** crossbow/RPG lead on a moving combine at range; the led point
should lead the NPC's actual movement.

### P1-11 Visual toggles gated by the wrong feature *(S)*
- `draw_fov_circle` is inside `esp_draw` (`esp.cpp:2084`, gated by
  `FEAT_ESP` at `menu.cpp:1083`) so it shows with aimbot off and hides with
  ESP off. Move the call to a neutral draw path or gate it on
  `aimbot_active || silentaim_active`.
- `draw_transitions` (`esp.cpp:2092`) has no setting at all. Add a toggle and
  default it off (its `transitions_scan` walks server entities every frame).
- `draw_fov_circle` rejects `fov >= 89` (`esp.cpp:606`) while the slider
  allows 180 (`gui.cpp:371`). Clamp the slider to the drawable range, or
  render the cone as a screen-space wedge instead of projecting axis points.

**Verify:** toggle ESP off with aimbot on → circle stays; toggle aimbot off
with ESP on → circle gone; transitions toggle actually stops the wireframes.

---

## 3. P2 — rendering and GUI correctness

### P2-1 Draw exactly once per frame *(M)*
**Where:** `hl2h.cpp:1008-1032`; guard is only `g_in_draw` (`:1003`), which
prevents nesting, not repetition. Both `hkdDrawPrintText` (`:1017`) and
`hkdPaintTraverseEx` (`:1027-1031`) call `outer_draw()`.

**Fix:** pick one owner. Preferred: `PaintTraverseEx` filtered to
`MatSystemTopPanel` (or the engine's root panel), and use `DrawPrintText`
only as a fallback if no paint happened in the current frame
(`last_paint_frame != g_frame_count`). Add a per-frame id so the guard is
"already drew this frame", not "currently drawing".

**Secondary (independent):** save and restore ISurface text state around
`outer_draw()` — `m_hCurrentFont` (`this+0x290`), `drawColor` (`+0x24`),
`textColor` (`+0x28`), `m_nTextX/Y` (`+0x2c/0x30`) — because
`PushMakeCurrent`/`PopMakeCurrent` only unwinds the clip stack
(`ISURFACE_STATUS.md:64-67`).

**Verify:** alpha-255 world tint and a 120-alpha ESP box must look identical
whether ESP is drawn once or with the old code; instrument a frame counter and
assert exactly one `outer_draw` per frame.

### P2-2 Single source of truth for widget geometry *(S now, folds into P3-2)*
**Where:** `gui.cpp:1347-1351` (`cr.x` includes `+S(10)`),
`:1948` and `:1627` (track from `cr.x`), `:1854-1855` (drag range omits the
`+S(10)` and is 10 px wider at the right edge).

**Immediate fix:** compute the track rect once in a helper
`slider_track(const Rect& cr)` and call it from `draw_slider`,
`handle_mouse_click`, and the drag handler. Add the missing `+S(10)` in the
drag range as a one-line stopgap if the helper is deferred.

**Verify:** click a slider knob at the far left/right, drag across; value must
track the cursor 1:1 and not jump on mouse-down.

### P2-3 Clipping correctness *(S)*
| Bug | Where | Fix |
|---|---|---|
| `line()` has no clip | `gui.cpp:920` | add `const Rect* clip` and clip via the same Cohen–Sutherland helper used for rects; use it for the checkbox checkmark (`:1941-1942`) and picker cursors (`:1300-1301`, `:1313-1314`) |
| `outline()` draws a border on the clip edge | `gui.cpp:911-918` | clip each of the four edges independently instead of outlining the clamped rect |
| `text_in()` shifts text left instead of clipping | `gui.cpp:872-888` | honour `max_w`: binary-search the longest prefix that fits, append `…`; never move `x` left |

**Verify:** scroll a card halfway past the content top/bottom — no border line
through the card, no checkmark floating in the header, long dropdown labels
truncate instead of overlapping the label column.

### P2-4 Pointer/click discipline in the GUI *(S)*
**Where:** `gui.cpp:1501-1522` (dropdown dismissal falls through),
`:1717-1772` (`handle_hover` ignores `g_dropdown_ctrl`).

**Fix:** a single `consume_click()` result from the dropdown block; when it
closes, return immediately. `handle_hover` early-outs while a dropdown, colour
picker drag, or slider drag is active. Also close pickers/dropdowns on
outside-click consistently (currently pickers need another colour control).

**Verify:** open a dropdown over a toggle row and click the toggle; the
dropdown must close and the toggle must not flip.

### P2-5 SDL interposition state tracking *(M)*
**Where:** `gui.cpp:140-141` (`dlsym(RTLD_DEFAULT, ...)` resolves the cheat's
own exported wrapper), `:2415-2434` (requests dropped while open),
`:1382-1385` (restores the pre-open snapshot), `:1410-1412` (snapshot).

**Fix:**
1. Resolve real symbols with `dlsym(RTLD_NEXT, ...)` in both wrappers (they
   already do internally at `:2417`/`:2430`; the public `sd_*` pointers must
   match).
2. Track *last requested* state while open: in the wrappers, record
   `g_game_wants_cursor` / `g_game_wants_relative` before forcing the GUI
   state, and restore those on close instead of the pre-open snapshot.
3. Don't drop all `SDL_MOUSEMOTION`; drop only the relative-motion deltas the
   game would consume, or key off `g_open` + "not over a GUI window".
4. Keep the `MOUSEWHEEL` watch but honour `direction == FLIPPED`
   (`SDL_MouseWheelEvent` at `+24`).

**Verify:** open GUI on the main menu → load a map → close GUI; mouse look must
work and the cursor must hide. Same sequence with alt-tab in the middle.

### P2-6 Chams material state *(M)*
**Where:** `studio.cpp:267` (AlphaModulate set), `:282-291` (`chams_reset`
never resets it), `:350-384` (forced override cleared for every pass,
including shadow depth), `:494-510` (same `IMaterial*` refcounted 2–4×),
`:122-131` (`hook_draw_model` never restores the previous modulation).

**Fix:** save the previous material state (colour modulation, `$color`,
`$alpha`, `AlphaModulate`) and restore it in `chams_reset`; dedupe material
names in `chams_preload` (one `IncrementReferenceCount` per unique material);
skip the override when the current pass is a shadow-depth draw (compare the
engine's existing forced override, or check the draw flags before clearing);
restore `SetColorModulation` after `hook_draw_model`.

**Verify:** chams on/off repeatedly with a flashlight/shadow-casting map;
shadows must not disappear; translucent chams must not stay semi-transparent
after toggling off; static prop tint must not bleed onto other models.

### P2-7 Font lifecycle *(S)*
**Where:** `gui.cpp:687-703` (create inside the family loop),
`:704-713`/`:725-727` (reset on level/scale change), `menu.cpp:1107-1131`
(same pattern).

**Fix:** create once per `(scale, DPI, font file)` and keep the handles; try
families by `AddGlyphSetToFont` on one handle where possible. Level changes
must not reset fonts at all (VGUI fonts are global). On scale change,
recreate once and log; accept the bounded leak because `ISurface` has no
`DestroyFont`.

**Verify:** die/respawn 20× and change UI scale 5×; log the `HFont` counter —
it must not grow per level change.

### P2-8 Projection and resolution *(M)*
**Where:** `esp.cpp:57-79` (`project_manual` ignores the
aspect/(4/3) scaling Source applies at `view.cpp:1075-1083`, and ignores
roll), `:2078`, `:2925`, `:3301` (screen size cached once).

**Fix:**
1. Prefer `IVDebugOverlay::ScreenPosition` everywhere; keep manual projection
   only as a fallback, and in the fallback apply
   `tanHalfH *= aspect / (4.0f/3.0f)` and include the roll term from
   `g_cam_view.z`.
2. Refresh `g_scr_w`/`g_scr_h` every frame (or on `GetScreenSize` change) —
   mirror what `gui.cpp:928-930` already does.
3. Don't mix `world_to_screen` and `project_manual` across one segment
   (`esp.cpp:2006-2009`); pick one per segment.

**Verify:** 16:9, 16:10, 21:9, and windowed resize mid-session: blast rings and
trajectory arcs must stay glued to the world; no kink at the near plane.

### P2-9 Skybox lifetime *(M)*
**Where:** `world.cpp:752-761` (in-place HDR linearisation), `:953-959`
(`skybox_invalidate` keeps texture + pixels), `:699-705`/`:720-729`
(originals stored without refcount), `:961-968` (`g_sky_tex = nullptr` leaks
the procedural texture).

**Fix:** keep a pristine copy (`g_sky_pixels_src`) and rebuild the working
buffer on each apply; `AddRef` originals when recorded and release on
invalidate; detach the regenerator and `DecrementReferenceCount` the old
procedural texture on reload. Either implement the documented
`SetUndefined()`/`Refresh()` path or update `CONTEXT.md:633-635` — right now
the docs describe code that does not exist.

**Verify:** HDR map → LDR map → HDR map; sky brightness must reset each time.
No material-system assert on map change with a custom skybox.

---

## 4. P3 — architecture

Do these only after P0/P1 are green. Each is behaviour-preserving and can land
independently. Order is by bug-surface reduction per unit of risk.

### P3-1 Overlay layer: one ISurface wrapper *(M)*
`menu.cpp` (`SURF_*`/`surf_*`), `esp.cpp` (`SurfSlot`/`surf_*`), `gui.cpp`
(`GS_*`/`s_*`) each hardcode the same slots. Introduce `overlay.h/.cpp` with
the slot table, `surface_*` primitives, text drawing, clip handling, and the
`outer_draw()` dispatcher. Everything else calls it. This also gives P2-3/P2-1
one place to fix.

### P3-2 Declarative settings table *(M)*
Replace the current six-place edit (header extern, definition,
`config_save`, `config_load`, `config_reset_defaults`, `build_cards`) with one
table:
```cpp
struct SettingDesc { const char* section; const char* key; Type type;
                     void* ptr; double def, min, max; const char* label; };
```
Generate save/load/reset/clamp from it; `build_cards` consumes the same
descriptors. Eliminates P0-6 and the duplicated clamp bounds, and makes new
options cheap.

### P3-3 Split `esp.cpp` *(L, mechanical)*
Target files, keeping current call graph: `entity_query.cpp` (class names, NPC
relation, viewmodel/owned-weapon helpers — also the home for `entity_dormant`
from P1-1), `combat.cpp` (aimbot/silent/triggerbot/melee/spread search),
`esp.cpp` (boxes/skeleton/arrows/speed graph/transitions), `items.cpp`,
`projectiles.cpp`. No behaviour change; move code verbatim, then fix includes.

### P3-4 Session-reset registry *(S)*
One `session_reset()` called from the map-change path (`hl2h.cpp:784-793`
already has the hook point) that clears every file-scope cache:
`bones_clear`, `srv_checked`, `owned_mask`, `proj_first_seen`, bone ring
buffer, `g_scr_*`, spread state, aim/silent locks, item/proj arrays,
`g_map_last/prev`. Add a debug counter so a forgotten cache is visible.

### P3-5 Header split and dependency direction *(M)*
Split `hl2h.h` into `common.h` / `core.h` / `settings.h` / `interfaces.h` /
`netvars.h` / `server.h` / `entity_query.h` / `bones.h` / `trace.h` /
`overlay.h` / `ui.h`. Enforce: features never include `hooks.h`/`init.h`;
`overlay.h` is the only place that knows ISurface slots; `common.h` depends on
nothing. This kills the star include graph and the circular
`hl2h.cpp ↔ menu.cpp` / `esp.cpp ↔ menu.cpp` / `studio.cpp ↔ esp.cpp` edges.

### P3-6 Hook registry and teardown *(S)*
A small table of `{name, target, slot, original, replacement, installed}` with
`install_all()` / `restore_all()`, used by `hooks.cpp`. Enables the readiness
gate (P0-1) to be uniform and gives you an unload path for testing.

### P3-7 Build and launch hygiene *(S)*
- `build.sh`: add `-pthread` (code uses `pthread_create`,
  `std::mutex`, and links `-Wl,--no-undefined`); add object files +
  dependency tracking for incremental builds; add an optional `-g`/`NDEBUG`
  switch so crash addresses are symbolisable; fail loudly with a clear message
  if `-m32` is unavailable; verify `assets/` is next to the built `.so`.
- `play.sh`: don't export `LD_PRELOAD` to the 64-bit Steam runtime wrapper
  (every helper process warns about `ELFCLASS32`); set it only for the 32-bit
  `hl2.sh` invocation, or re-exec the 32-bit binary directly. Move the
  hardcoded `+map d2_coast_03` into a variable.
- Add `crash` symbolisation instructions (`addr2line -e libhl2hake.so`).

---

## 5. Testing strategy

The codebase has no tests, which is why most of these bugs are found by
inspection. Most of the P1/P2 defects live in pure functions; extract and test
them natively.

1. **`tests/` with a 30-line assert harness**, compiled with the *host*
   compiler (no `-m32`), no engine deps:
   - config parser + `SettingDesc` clamping (P0-6, P3-2)
   - `vector_angles` / `angle_to` / FOV math (P1-8)
   - clip-rect and `text_in` truncation (P2-3)
   - bone-cache eviction/validity policy as a pure `BoneCache` class
     (P1-3)
   - `proj_first_seen` ring (P1-9)
   - seed selection / spread scoring (P1-6)
   - `project_manual` aspect/roll (P2-8)
   Compile with `-fsanitize=address,undefined`; run in CI-ish `make test`.
2. **Hook-level assertions** (debug builds only): stage must be 3 before a
   feature runs; `find_modules` only from the setup thread; `seq >= 0`;
   `g_srv_*` only read after a successful resolve.
3. **In-game smoke matrix** — run after every phase:
   | Scenario | Covers |
   |---|---|
   | `+map` direct load, 10× | P0-1, P0-2 |
   | main menu → load map → close GUI | P2-5 |
   | death / respawn 20× | P2-7, P1-2 |
   | map change A→B→A (HDR and LDR) | P1-2, P2-9, P3-4 |
   | >64 NPCs on screen | P1-3 |
   | resolution change + windowed resize | P2-8 |
   | 60-min session | font/cache/leak growth |
   | shotgun/pistol/crossbow strings | P1-5, P1-6 |
4. **Regression corpus:** keep `crash`, plus `addr2line` output per fixed
   item, so a reintroduced crash is recognisable.

---

## 6. Verified NOT a bug — do not fix

These appeared in the audit but did not survive verification. Listed so they
don't get "fixed" into new bugs.

| Claim | Reality |
|---|---|
| NPC `m_vecVelocity` read is OOB | Offset is `C_BaseEntity::m_vecVelocity` (`0xE0`), valid on all entities. Real issue is that NPCs don't network it → P1-10. |
| `CUtlVector` layout is wrong (`+12/+16`) | Correct for 32-bit (`utlvector.h:224-230`). Only range validation missing → P0-4. |
| Sky `memcpy` can overflow `dst` | Texture created at PNG dims, RGBA8888, so `pitch >= w*4 >= copy`. Add an assert, no fix. |
| `config_load` null deref is P0 | Real but only reachable from a hand-edited config (`config_save` never writes those keys). → P0-6, low. |
| `fog_enableskybox` stays on | `world.cpp:345-346` handles the `else` branch; the logic is correct. |
| Shotgun pellets need 48 `RandomFloat` draws | `RandomSeed` is called per pellet (`baseentity_shared.cpp:1699`), so 8 draws per pellet is enough. |
| Held `IN_ATTACK` breaks semi-autos | Source treats held `IN_ATTACK` as continuous fire; the concrete defect is the missing release edge for the pistol refire reset → folded into P1-5.5. |
| `bones_update` serves stale bones today | The missing `valid=false` is real but every in-tree caller checks the return. Fix anyway (P1-3) because it is one line. |
| `owned_mask` ORs forever | It resets when the local entity pointer changes; it is a same-map drop bug only. |
| `info_model_name` reads OOB | `name[64]` is followed by other header fields, so the 128-byte scan stays inside `studiohdr_t`. Correctness nit only (P0-8). |
| `draw_fov_circle` has no GUI toggle | Toggle exists (`gui.cpp:375`). Real bug is the `FEAT_ESP` gate → P1-11. |
| `%ebp` is safe because `-fno-omit-frame-pointer` | That flag only applies to this `.so`; callers are Valve binaries → P0-3. |

---

## 7. Sequencing summary

| Phase | Items | Effort | Gate to next phase |
|---|---|---|---|
| P0 | 8 | ~1.5 days | 10× direct-load and menu-load runs with no crash; netvars stable across map change |
| P1 | 11 | ~2.5 days | smoke matrix passes; aimbot/vischeck manual acceptance |
| P2 | 9 | ~2 days | visual acceptance (alpha, clipping, chams, skybox) + resize test |
| P3 | 7 | ~3 days | `tests/` green, `make` incremental, no behaviour diff on smoke matrix |

Quick wins if you only have an afternoon: **P0-1, P0-5, P0-6, P0-7, P0-8**,
plus **P2-2** (slider 10 px) and **P1-6** (spread gate). Those are all
small, local, and remove a crash class or a user-visible bug each.

---

## 8. Implementation log

### Batch 1 — done, builds clean

| Item | Status | Notes |
|---|---|---|
| P0-1 hook readiness gate | done | `init_stage()`/`init_live()` with acquire/release; all hooks pass through until `INIT_LIVE`; setup order is now config+surface → input → client hooks. `INIT_FAILED` on every fatal setup path. |
| P0-2 module snapshots | done | `Module` globals replaced by `mod_client()`/`mod_engine()`/`mod_server()` returning an immutable, atomically published snapshot. Retired snapshots are intentionally leaked so a reader's reference never dangles. No lock on the read path. |
| P0-3 `%ebp` walk | hardened | New `caller_self_arg()` validates that the walk stays inside the current stack (above `%esp`, <1 MiB, 4-aligned) before dereferencing. Still frame-pointer based; a future pass should drop it in favour of arming from `CreateMove` (option 1 in P0-3). Spread window now reset at every `do_move`. |
| P0-4 netvar bounds | done | `module_range(p, n)` added; every `RecvTable`/`RecvProp`/string/array dereference is validated before use. |
| P0-5 vtable header | done | `vtable_clone()` preserves `[-2]/[-1]`; used by client, CInput, IVModelRender and IStudioRender hooks. |
| P0-6 config `cur` | done | Stray `active=`/`bind=` in scalar/chams sections is ignored instead of dereferenced. |
| P0-7 signed `seq` | done | `seq < 0` rejected; modulo uses `unsigned`. |
| P0-8 defensive batch | partly | Bone parent-cycle guard, `mprotect` return check and no `PROT_EXEC`, `server.so` `.bss` tail absorbed, studio name bounded to 64. Not done: `build_core_mask` caching. |
| P1-6 spread gate | done | Seed hook now arms on `no_spread` only; `engine` toggle controls only the `RandomFloat` zeroing. |
| P2-2 slider geometry | done | `slider_track()` is the single source of truth for draw, hit-test and drag; drag clamps `t` to `[0,1]`. |

Verification for this batch is compile-level (`build.sh`, `--no-undefined`)
plus a diff review. In-game runs still needed for P0-1/P0-2/P0-3 as described
in their own sections.

### Batch 2 — done, builds clean

| Item | Status | Notes |
|---|---|---|
| P1-1 dormant entities | done | `entity_is_dormant()` on `IClientNetworkable` slot 8, applied in esp_collect, aimbot candidate scan, the NPC visibility helper, melee, item ESP and projectile ESP. Slot layout is self-tested against the local player (which is never dormant): a wrong slot disables the check with a log line instead of misreporting every entity. |
| P1-2 `srv_checked` latch | done | Moved to file scope, exposed via `esp_reset_session()` called on local-entity change; only latches once both client and server model indices are valid. |
| P1-3 bone cache (part) | partly | `e->valid = false` now set on the two early returns that skipped it. Still to do: cache sizing/eviction and the `head_position` early-return. |
| P1-4 vischeck | done | Glass stepping advances until a probe is clear (doubling, capped, never past the target) instead of a fixed 0.5 units; `VisibilityFilter` ignores entities owned by the target via `m_hOwnerEntity`. |
| P1-5 aimbot | part | `maxs < 0` no longer disables the aimbot; prefilter widens the FOV test by the AABB angular radius; lock uses 1.5× hysteresis instead of no limit; the projectile lead point is re-checked for FOV and visibility and falls back to the un-led point. Still to do: `IN_ATTACK` release edge. |
| P1-7 owner netvars | done | `m_hOwnerEntity` / `m_hOwner` now resolved (previously always −1). |
| P1-8 math/movement | part | `vector_angles` pitch no longer wraps to `[270,360)`; `point_ground` requires `normal.z >= 0.7`; underwater jump+duck no longer skips combat (`goto bhop_done`); melee AABB rejects hugely negative `mins`. Still to do: triggerbot `entindex()`. |
| P1-9 fuse table | done | 64-entry ring with stale-entry reset; no longer freezes fuse time after 32 projectiles. |
| P1-11 FOV circle | done | Moved out of `esp_draw` into `outer_draw`, gated on aimbot/silent-aim being active plus the toggle, with its own screen-size init. |
| P2-1 draw once per frame | part | `outer_draw()` now has a 1 ms gate (panels within a frame are microseconds apart) and `DrawPrintText` only acts as a fallback when no paint happened for 50 ms. Still to do: save/restore ISurface text state. |

### Batch 3 — done, builds clean

| Item | Status | Notes |
|---|---|---|
| P2-3 clipping | done | `line()` is now Liang–Barsky clipped and used by checkbox checkmarks and picker cursors; `outline()` draws four clipped edges instead of outlining the clamped rect; `text_in()` truncates with `...` at `max_w`/clip edge and no longer shifts left. `s_outline` removed. |
| P2-4 pointer discipline | done | Clicking outside an open dropdown consumes the click (no activation of the control underneath); `handle_hover` no longer arms hidden sliders while a dropdown, picker, or drag is active. |
| P2-5 SDL state | part | `sd_*` pointers now resolve with `RTLD_NEXT` instead of `RTLD_DEFAULT` (which found this file's own wrapper); the wrappers record what the game last requested and `close_gui()` restores that instead of the pre-open snapshot. Not changed: dropping `SDL_MOUSEMOTION` while open. |
| P2-6 chams | part | `chams_reset()` now resets `AlphaModulate` (shared materials were permanently left half-faded); `chams_preload()` references each unique material once; prop tint restores white after `DrawModel`/`DrawModelStaticProp`. Not addressed: clearing `ForcedMaterialOverride` during shadow-depth passes (needs a way to read the engine's current override). |
| P2-7 fonts | part | `create_gui_font()` creates one handle and adds glyph sets to it instead of leaking one per failed family; `ensure_fonts()` keeps valid handles across level changes and only recreates when `font_valid()` fails. `menu.cpp`'s own font pair still resets on local-entity change. |
| P2-8 projection | done | `project_manual()` applies Source's 4:3-reference aspect scaling and includes roll; screen size is refreshed every frame through `esp_update_screen()` in all four draw paths. |
| P2-9 skybox | part | Pristine `g_sky_pixels_src` so HDR linearisation no longer bleeds into a later LDR map; `skybox_reload_image()` detaches the regenerator before dropping the handle. Original `ITexture*` refcounts still not taken (needs the ITexture slot verified against the shipped binary). |
| P3-4 session reset | done | One `session_reset()` called on local-entity change, listing every per-map cache. |
| P3-7 build | done | `-pthread`; incremental objects in `build/{release,debug}` (verified: second run compiles nothing); `DEBUG=1` for `-O0 -g`; explicit 32-bit toolchain check; assets warning; `build/` ignored. |

Self-review during this batch caught one bug in my own P1-1 change: the
dormant self-test could run before a local player existed (main menu) and latch
"disabled" permanently. It now leaves the state untested until a local player
is available.

### In-game validation

Batch 1–3 items were exercised by the user in a live game and all behaved
correctly. Two follow-ups were found and fixed:

- speed-graph background flicker — the 1 ms draw gate was not a frame boundary
  (see the P2-1 follow-up below);
- FOV circle never visible — it was gated on `feature_active()`, which requires
  the bind (default mouse5) to be held; now uses `feature_enabled()`.

Both confirmed fixed.

### Batch 4 — done, builds clean

| Item | Status | Notes |
|---|---|---|
| P3-1 overlay layer | done | New `overlay.h` / `overlay.cpp` own the VGUI_Surface030 slot table and every ISurface call. `g_surface` moved there; `menu.cpp`, `gui.cpp` and `esp.cpp` keep their local wrapper names as one-line forwarders, so no call sites changed. Three duplicated slot tables are gone. |
| P1-3 bone cache | done | Cache raised to 192 entries (128 targets + room for failed updates); `head_position()` continues past a name-matched bone whose matrix is invalid instead of aborting the search. |
| P1-5 fire control | done | `fire_edge_ok()` alternates press/release for semi-auto weapons (pistol, .357, crossbow, shotgun, RPG, gravity gun, crowbar) and holds the button for automatic ones. Applied to silent aim, melee/gravity and triggerbot. |
| P1-10 NPC lead | done | New `server_entity_velocity()` reads the listen-server `m_vecVelocity` (already resolved but unused); `projectile_lead_point()` prefers it and falls back to the client field. |
| P2-9 `ITexture` refcounts | done | `record_orig()` now takes a reference (slot 10) on every captured texture and `release_sky_originals()` drops them (slot 11) wherever the list is cleared, so a map change can no longer leave `g_sky_orig` holding freed pointers. Slot numbers follow the material-system header order already verified here (SetTextureRegenerator = 12, Download = 13). |
| P2-5 motion events | done | `SDL_PollEvent` now keeps `SDL_MOUSEMOTION` events and zeroes only `xrel`/`yrel`, instead of dropping the event entirely (VGUI hover and SDL cursor state stay consistent). |
| P1-11 follow-up (circle) | done | The circle was gated on `feature_active()`, which requires the bind to be held; the default aimbot bind is mouse5, so it only appeared while aiming. `feature_enabled()` (toggle only) is now used, and the circle is skipped when there is no local player (main menu). |
| P2-1 follow-up (flicker) | done | The 1 ms time gate was the wrong frame boundary: panels more than 1 ms apart in one frame drew twice, stacking the 120-alpha speed-graph background, so it alternated brightness. `overlay_frame_mark()` is now called from `FRAME_RENDER_START` and `overlay_should_draw()` allows exactly one draw per engine frame, with a 10 ms fallback for menu/pause frames that never send the stage. |

### Performance pass

The audit's hot paths were removed rather than micro-optimised:

| Cost | Before | After |
|---|---|---|
| NPC classification from the chams hook | `esp_npc_relation()` did `esp_is_npc_index` → entity/model lookup → `server_npc_relation` (edict + `CUtlVector` scan) + `server_npc_state` per model draw, up to 3 passes per entity per frame | 50 ms TTL cache keyed by entity index; a draw now costs a hash lookup |
| Friendly/neutral test | `server_npc_relation` + `server_npc_state` (+ turret flags) per call, from the entity scan, aimbot selection and chams | 100 ms TTL cache |
| Damage class | `server_npc_takedamage` (edict lookup) + ~20 `strcasestr` per NPC per scan | 250 ms TTL cache |
| Model name | two `IVModelInfo` virtual calls per entity per scan (~1000 entities at 100 Hz) | memoised by model index, cleared on map change |
| Triggerbot index recovery | walked up to 4096 entity-list slots per fire tick | direct-mapped entity-pointer → index cache filled by the entity scan; the scan remains only as a cold-miss fallback |

Item and projectile markers were the other half of the problem: discovery
(`items_collect` / `projs_collect`) is throttled to 10 Hz because it walks the
entity list, but both draw paths already stored the entity index. They now
refresh the origin from the live netvar per frame (`entity_origin()`), so
markers, blast-radius rings and the grenade trajectory follow the world at
render rate while the expensive scan stays at 10 Hz. The trajectory also uses
the networked `m_DmgRadius` instead of a hardcoded 300, and its landing ring
respects the "Blast radius" toggle.

Caches are cleared by `esp_reset_session()` on map change/respawn. The ESP scan
itself is unchanged: projection only happens for the handful of entities that
survive the class/life/friendly filters, not for every entity.

### Batch 5 — chams shadow passes

`IVModelRender::ForcedMaterialOverride` (slot 1) is now hooked as well, purely
to track the engine's current forced material. `DrawModelExecute` skips chams
when an override is already active, so shadow-depth and reflection passes draw
with their own material instead of being substituted with chams and then having
the engine's override cleared for every model drawn afterwards. This is the fix
for the corruption described in P2-6; it needs an in-game check because it
relies on the engine always going through the vtable for that call.

### Batch 6 — hook registry

`hooks.h` / `hooks.cpp` own vtable installation: one call clones, bounds-checks
every slot, records the originals and installs the clone, with a mutex because
installs happen from the setup, game and paint threads. `hooks_restore_all()`
puts every interface back, which previously was impossible. Client, CInput,
IVModelRender, IStudioRender and ISurface all go through it now.

### Batch 7 — header split

`hl2h.h` is now an umbrella over `core.h` (logging, init stage, module
snapshots, interfaces, netvars, CUserCmd, engine constants, vector math),
`settings.h` (feature ids, feature state, every user setting) and `features.h`
(cross-module declarations), plus `overlay.h` and `hooks.h`. Translation units
still include `hl2h.h`, so this is a declaration-organisation change only.

### Batch 8 — esp.cpp split

`esp.cpp` went from 3665 to ~1000 lines and now only scans and draws. The other
halves are `entity_query.cpp` (entity lookups, NPC classification, per-map
caches, session reset), `combat.cpp` (aimbot, silent aim, melee, spread,
triggerbot), `items.cpp` and `projectiles.cpp`. Cross-unit helpers are declared
in `esp_internal.h`; nothing changed behaviourally.

### Batch 9 — settings table

`config_table.cpp` holds one descriptor per persisted setting: section, key,
type, pointer, default and clamp range. `config_save()`, `config_load()` and
`config_reset_defaults()` are generated from it, replacing the hand-written
writer, the ~350-line parser and the duplicated `[view]` clamp block. Section
and key names are unchanged, so existing config files still load. This needs a
save → reload → reset round-trip in-game.

### Known remaining work

Deliberately deferred, with reasons:

- **P1-8 triggerbot `entindex()`** — the current code is correct, only O(N)
  per fire tick. Replacing it needs a verified `IClientNetworkable::entindex`
  slot plus a way to get the networkable from a raw entity pointer, or a
  cached pointer→index map. Not worth the risk for a hot path that fires a few
  times per second.
- **P3-2 GUI binding** — the settings table now drives persistence (save,
  load, reset); `build_cards()` still binds widgets to globals directly. Folding
  the GUI metadata into the same table is the only remaining piece, and it is
  cosmetic: no setting is duplicated in code any more, only in the GUI layout.

Nothing in the two batches has been exercised in-game yet. The smoke matrix in
§5 is the next step: direct map load 10× (P0-1), menu → map → GUI close (P2-5),
map change A→B→A (P1-2/P2-9), >64 NPCs (P1-3), and a 60-minute session.