summaryrefslogtreecommitdiffstats
path: root/CONTEXT.md
blob: 741050ddb99a05ca218291897627cf04d07a4e5d (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
# Context: HL2 Linux Internal Cheat (`hl2hake`)

## What this is

A 32-bit Linux internal for Half-Life 2 (native `hl2_linux`), loaded with
`LD_PRELOAD` through Steam's bundled 32-bit runtime. It started as a port of
the Windows `hake` reference (auto-bhop / auto-ABH / auto-climb) and now also
covers ESP, aimbot, triggerbot and silent aim.

## Build / run

- `./build.sh` -> `libhl2hake.so`
  (`g++ -m32 -O2 -std=c++17 -Wall -Wextra -Werror -fPIC -shared`,
  sources: `hl2h modules cinput netvars menu esp server bones vischeck`, `-ldl`).
- `./play.sh` -> Steam runtime + `hl2.sh -steam -novid -console +map d1_town_01`.
- Logs: `/tmp/hl2hake.log` (`logf`) and stderr. `./play.sh > /tmp/hl2run.log 2>&1`
  captures everything.
- Debug env:
  - `HL2H_DRAW_TEST=1` — red probe rect + text, no menu needed.
  - `HL2H_BONE_DEBUG=1` — white marker at each NPC head hitbox.
  - `HL2H_NO_SERVER=1` — disable server-side health reads.
- Config: `$XDG_CONFIG_HOME/hl2hake/config`, falling back to
  `~/.config/hl2hake/config`. Plain INI, saved on every change.

## Runtime architecture

- Modules from `/proc/self/maps`: `client.so`, `engine.so`, `server.so`.
  PROT_NONE ranges excluded; file-backed bss tails merged.
- Calling convention: cdecl (i386); `this` is the first stack argument.
- Hooks (copy-and-swap vtable, 256 slots):
  - `CHLClient` slot 21 `CreateMove`; slot 35 `FrameStageNotify`.
  - `ISurface` slot 114 `PaintTraverseEx` (primary draw);
    slot 22 `DrawPrintText` fallback. Draws wrapped in
    `PushMakeCurrent`/`PopMakeCurrent`.
- `CInput*` discovered by byte-scanning the `CHLClient::CreateMove` prologue.
  Usercmd CRC-32 is repaired after any cmd edit (`note_modified`).
- Console output via `ConMsg` from `libtier0.so`
  (`_Z6ConMsgPKcz`), not `IVEngineClient::ClientCmd`.

## Interfaces / indices

| Interface | Version | Notes |
|---|---|---|
| Client | `VClient017` | |
| Entity list | `VClientEntityList003` | |
| Engine | `VEngineClient014` | |
| Model info | `VModelInfoClient006` | |
| Engine server | `VEngineServer021` | edicts / listen-server entities |
| Debug overlay | `VDebugOverlay003` | accurate world->screen |
| Engine trace | `EngineTraceClient003` | vischeck / trigger ray |
| Surface | `VGUI_Surface030` | |

Relevant vtable slots (this build):

- Engine: `GetLocalPlayer` 12, `Time` 14, `GetViewAngles` 19, `SetViewAngles` 20.
- Entity list: `GetClientNetworkable` 0, `GetClientEntity` 3,
  `GetHighestEntityIndex` 6.
- `IClientNetworkable`: `GetClientClass` 2. `ClientClass::m_pNetworkName` at +8.
- `IClientUnknown`: `GetClientRenderable` 6.
- `IClientRenderable`: `GetModel` 9, `SetupBones` 16.
- `IVModelInfo`: `GetModel` 2, `GetModelName` 4, `GetStudiomodel` 29.
- `IVEngineServer`: `PEntityOfEntIndex` 19.
  Edict `m_pUnk` at +0xC; `IServerUnknown::GetBaseEntity` 6.
- `IVDebugOverlay`: `ScreenPosition` 9.
- `IEngineTrace`: `TraceRay` 4. `Ray_t` uses 16-byte `VectorAligned`;
  `trace_t`: `fraction` 44, `startsolid` 55, `m_pEnt` 76.

## Netvars / entity data (this build)

| Field | Client offset |
|---|---|
| `m_fFlags` | `0x33C` |
| `m_vecVelocity[0]` | `0xE0` |
| `m_MoveType` (prediction datamap) | `0x164` |
| `m_iHealth` | `0x80` |
| `m_iMaxHealth` | not networked |
| `m_iTeamNum` | `0x88` |
| `m_vecOrigin` | `0x324` |
| `m_vecViewOffset[0]` | `0xD4` |
| `m_vecMins` / `m_vecMaxs` | `0x1A8` / `0x1B4` |
| `m_lifeState` | `0x7F` |
| `m_nModelIndex` | `0x7C` |
| `m_vecAbsVelocity` (client local, not netvar) | `0x11C` |
| `m_hActiveWeapon` (client netvar; server fallback `0x824`) | resolved by name |
| `m_hOwnerEntity` / `m_hOwner` (held weapons) | resolved by name |

- Eye position = `m_vecOrigin + m_vecViewOffset` on **all three axes**.
  (The old code added the X component to Z, which left the eye ~64 units low.)
- NPC health is not networked. Alive state comes from the networked client
  `m_lifeState`; real HP is read from the listen-server `CBaseEntity` found via
  `PEntityOfEntIndex -> edict->m_pUnk -> GetBaseEntity`, using offsets scanned
  from `server.so`'s datamap:
  - `m_iHealth` `0xF4`, `m_iMaxHealth` `0xF0`,
    `m_lifeState` `0xF8`, `m_nModelIndex` `0x7A` (short).

## Menu

There is a single menu: the custom GUI, toggled with `Insert`. The old
text-tree menu was removed after the GUI reached parity. Every feature still
has an active flag and a bind (SDL scancode; mouse buttons 600..604; None =
always-on), and all settings are persisted in the INI described under GUI
vocabulary / decisions.

## GUI vocabulary / decisions

- **GUI**: custom immediate-mode settings surface drawn through the existing
  `ISurface` paint path. No Dear ImGui, no OpenGL hook, no new runtime
  dependency.
- **Category**: sidebar group. Movement, Combat, Visuals, Weapons, System.
- **Card**: per-feature panel. Header always shows Active + Bind; body is a
  collapsible set of settings.
- **Text layout**: measured with `ISurface::GetTextSize` and wrapped to the
  available width. Card headers, control rows, info rows, tooltips and toasts
  grow/wrap instead of truncating; there is no ellipsis truncation. The config
  path is shown on the System page (not duplicated in the footer).
- **Freeze**: while the GUI is open, look/attack/use/impulse/weapon selection
  are blocked and combat features are skipped, but normal WASD/jump/duck/walk
  movement is allowed. Mouse deltas are discarded before `CInput::CreateMove`
  can apply them (`CInput::ClearStates`), so the view cannot drift for a frame.
  Bind capture blocks all game input until it ends. The game keeps simulating
  and ESP/FOV circle/speed graph keep drawing. No invulnerability.
- **Native cursor**: on open, leave SDL relative-mouse mode and show the OS
  cursor; restore on close. Absolute mouse coordinates are used.
- **Toast**: transient top-right notification drawn by the overlay even when
  the GUI is closed. Info/warn/error colors; lifetimes 6/9/12 s. Toasts queued
  before the first paint (load errors) start their timer when first drawn.
  Used for the load message, config problems, missing interfaces/netvars,
  bind changes, Reload and Reset.
- **Bind capture**: click Bind, press a key or any mouse button; Esc cancels
  capture but does not close the GUI; the small x clears the bind (None =
  always-on). Insert is reserved for the GUI.
- **UI scale**: `clamp(min(screen_w/1920, screen_h/1080), 0.75, 1.5)`; base
  window 960x620, body/title fonts 15/20 px at scale 1.
- Every feature gets Active + Bind; bind None means always-on.
- System page: real config path/build info, Reload config (re-reads the INI),
  and a two-click Reset that restores feature settings/binds but keeps GUI
  layout.
- Window: fixed-size, draggable by title bar, clamped to screen, position,
  category and expanded cards persisted under `[gui]` in the same INI. Esc
  does not close the GUI; the toggle key (Insert) is the only close path.
  There is no title-bar X.
- GUI controls write the shared feature state; sliders persist when
  the drag ends, toggles/binds/layout persist immediately. `[gui]` stores
  `x`, `y`, `category` and an `expanded` bitmask (card index order). Every
  feature now persists an `active` and `bind` pair, including movement, melee
  and spread.
- **Colour control (`CK_COLOR`)**: row shows a live swatch; clicking the row
  expands an inline picker (saturation/value square + hue bar + hex/RGB
  preview) instead of three RGB sliders, growing the card in place so it
  scrolls and clips like any other row. The picker keeps HSV state so grey
  colours do not lose their hue; dragging writes the 0-255 RGB triple shared
  with the config and saves on mouse release. Only one picker is open at a
  time; closing the GUI or collapsing the card hides it.
- Config writes are atomic: `config.tmp` is written and flushed, then
  `rename()`d over the real file. A failed write raises an error toast.


## NPC friendliness

- ESP colours, aimbot/silent-aim/melee target filtering use the server AI
  relationship (`m_Relationship` CUtlVector) against the local player:
  entity-specific rows first, then the CLASS_PLAYER class row. D_LI is
  friendly, D_NU neutral, D_HT/D_FR hostile.
- This makes map-scripted friendly turrets and other relationship overrides
  dynamic instead of model-name guesses. When no row exists, the old model
  heuristic is used.
- `m_Relationship` is declared with `DEFINE_UTLVECTOR`, so its datamap entry
  is `FIELD_CUSTOM` (not `FIELD_EMBEDDED`). Resolving it as embedded silently
  returned `-1` for every NPC.
- Floor turrets are special: citizen-built turrets (`SF_FLOOR_TURRET_CITIZEN`,
  spawnflags `0x200`) and Alyx-hacked turrets (`m_bHackedByAlyx`) report
  `CLASS_PLAYER_ALLY` from their overridden `Classify()` and never add a
  relationship row. They are classified from those server fields instead.
- Bugbait-controlled antlions are also treated as friendly when their server
  `m_hFollowTarget` is set, covering antlions that spawned before the global
  `antlion_allied` state changed.
- Stock NPCs have no `m_Relationship` rows at all: their disposition comes from
  the global `CHL2GameRules::InitDefaultRelationships()` table indexed by
  `Classify()` (e.g. `CLASS_PLAYER_ALLY -> D_LI`, `CLASS_HEADCRAB -> D_HT`).
  That table is not stored per entity, so `relation=-1` is normal and the
  model heuristic is the fallback for those NPCs (Kleiner, Combine, ...).
- `m_NPCState` (`FIELD_INTEGER`, server datamap) is read as well:
  `NPC_STATE_SCRIPT` (4) marks scripted/passive actors as neutral, so they are
  not aimbot targets and draw with the neutral colour. Lamarr
  (`models/Lamarr.mdl`) is additionally listed as neutral by model.

## Bones / ESP

- `SetupBones(BONE_USED_BY_ANYTHING)` with engine `Time()`; studio header via
  `GetStudiomodel`; hitboxes and bone names/parents from that header.
- HL2 strips many ValveBiped bone names (the zombie's spine/legs/left arm are
  unnamed), so the humanoid skeleton is built from the **union of hitbox bone
  parent paths** (`HITGROUP_*`). Head group 1 includes the zombie headcrab body
  and its `HC_Head` child; fingers, toes and other `HC_*` limbs are excluded.
- Non-humanoid NPCs (headcrabs, crows, antlions, ...) draw the full bone tree.
- `aim_points` = head hitbox/bone + hitbox centers for the selected groups;
  the aim features pick the nearest visible point.

## Aim features

- Shared target selection (`select_aim_target`): live hostile NPCs, skip
  friendly/neutral, rank by angle to the crosshair, then pick the nearest
  selected hitgroup point. Target candidates are tried nearest-first.
- Vischeck: `EngineTraceClient003::TraceRay`, `MASK_VISIBLE`,
  `TRACE_WORLD_ONLY` (world only; static props are not tested — deliberate).
- Aimbot: bounded smoothing — 0.05 deg deadzone, max `Smoothness` deg/tick,
  shortest-path yaw, pitch clamped to ±89; pushes the new view with
  `SetViewAngles`.
- Triggerbot: crosshair ray with `MASK_SHOT` + `TRACE_EVERYTHING` (filter
  ignores the local player). First hit must be a live hostile NPC; sets
  `IN_ATTACK`.
- Silent aim: exact target angle written to `cmd->viewangles`, `IN_ATTACK`,
  then the rendered view is restored immediately **and** at
  `FRAME_RENDER_START`; `forwardmove`/`sidemove` are rewritten to preserve the
  world-space movement wish vector. Because `IN_ATTACK` is set after
  `CInput::CreateMove`, the minimal-spread seed is applied again in `do_move`
  (same for triggerbot). Crossbow bolts and RPG rockets are led by target
  velocity and travel time; the crossbow also gets its 0.05 gravity drop.
- ABH: on the landing/jump tick, face opposite the horizontal velocity with
  zero forward/side movement; HL2's jump speed-bound subtraction then
  accelerates along the velocity. Camera restored at `FRAME_RENDER_START`.

## Melee killbot

- Active only while the local player's active weapon is the crowbar. The
  client `m_hActiveWeapon` handle is used first; if it is missing, the
  authoritative server `m_hActiveWeapon` handle (0x824) is used to get the
  weapon entity index.
- Map transitions often switch the active weapon away from the crowbar. The
  state change is logged as `melee: active ... crowbar=0/1` (normal log), and
  a one-time toast says to equip the crowbar. If the client weapon model is
  missing/0, the server model index is used to recognize it.
- Bone caches and aim locks are cleared when the local player entity changes
  (map load/respawn).
- Crowbar range is `CROWBAR_RANGE` 75 units from `Weapon_ShootPosition`. The
  selector computes the closest point on each hostile NPC's AABB to the eye,
  rejects anything farther than 75, sorts by that distance, and picks the
  nearest visible one.
- The usercmd view is pointed at the target for one command with `IN_ATTACK`;
  movement keys are rotated with `preserve_movement`, and the rendered view is
  restored immediately and at `FRAME_RENDER_START` (same silent-aim path).
  This gives a 360-degree silent melee attack.
- While the crowbar is active and the killbot feature is on, the gun aimbot,
  triggerbot and silent aim are skipped to avoid fighting over the usercmd
  view.
- Vischeck uses the shared `MASK_VISIBLE`/world-only path (static props are
  ignored).

## Spread control

- HL2's `CBaseEntity::FireBullets` seeds its RNG from
  `CBaseEntity::GetPredictionRandomSeed(info.m_bUseServerRandomSeed)`; HL2
  weapons leave `m_bUseServerRandomSeed` false, so the server uses the
  client usercmd's `random_seed` (masked to 8 bits). This is the TF2-style
  client-side seed hook.
- `Spread control -> No spread` precomputes, with the engine's own
  `RandomSeed`/`RandomFloat`, the 8-bit seed whose
  `CShotManipulator::ApplySpread` offset is smallest. It sets
  `cmd->random_seed` on attack commands; `note_modified` repairs the CRC.
  Silent aim and triggerbot set `IN_ATTACK` after the CInput hook, so they
  re-apply the best seed in `do_move`.
- Shotguns use consecutive seeds per pellet. The primary's first pellet is
  `FIRE_BULLETS_FIRST_SHOT_ACCURATE`, so the table excludes it; the secondary
  uses all 12. Separate best starting seeds are precomputed for 1, 8 and 12
  pellets.
- This attacks the random cone offset only. The cone size, the pistol's
  `m_flAccuracyPenalty` growth, the server view punch added to
  `GetAutoaimVector`, and any damage falloff are server-side state. In
  single-player those can be written/hooked locally; stock HL2 has no
  distance damage falloff.

## Jumpbug

- Automatic while falling when the predicted landing speed
  `sqrt(vz^2 + 2*600*x)` exceeds `PLAYER_MAX_SAFE_FALL_SPEED` (526.5).
- The engine only applies fall damage when `CheckFalling` sees a ground entity
  at the end of the frame. `PlayerMove` runs `Duck()` **before**
  `FullWalkMove`'s `CheckJumpButton`, so a same-frame `FinishUnDuck` sets ground
  via `CategorizePosition`, the jump executes, and the fall-damage test is
  skipped.
- In air, `FinishDuck` raises the origin by 36 (standing hull 72, duck hull 36)
  and `CanUnduck` only succeeds when the floor is at least 36 below; after
  unducking, `CategorizePosition` needs it within 2 more units. The unduck
  window is therefore a 2-unit band `[36,38]` above the floor.
- The feature presses duck when the standing distance `x` is within
  `drop + [0,2]` (where `drop` is the previous command's measured fall), then
  releases duck + `IN_JUMP` on the next command when `x` is in `[36,38]`.
  It also clears `IN_JUMP` while waiting so the pogo latch is clear.
- `drop` is measured from consecutive predicted origins (not a hard-coded tick
  interval). At high fall speeds the 2-unit band can be skipped between
  commands; a higher frame rate improves reliability. Best-effort by design.
- **Assist timing** (off by default): when the natural next command would jump
  over the 2-unit window, force the post-command distance to 37 by writing the
  vertical velocity. It needs both the authoritative server velocity and the
  client prediction velocity:
  - server `CBaseEntity::m_vecAbsVelocity` (datamap offset 0x228), read by
    `SetupMove` via `GetAbsVelocity`;
  - client `C_BaseEntity::m_vecAbsVelocity` (datamap offset 0x11C) and the
    networked `m_vecVelocity` (0xE0 + 8).
  The velocity is `dz / 0.015 + 0.5 * 600 * 0.015` where `dz` is the required
  displacement for this command. This turns the ~20% timing success at 625 ups
  into a repeatable setup, at the cost of one command with a nudged vertical
  speed. Pure jumpbug remains available with Assist off.
- The server datamap has two `m_vecVelocity` entries (0x1C and 0x29C), so the
  assist deliberately writes only `m_vecAbsVelocity`; `SetAbsVelocity` syncs
  `m_vecVelocity` after the command.

## World transformations

### Fullbright

- Visuals card with the usual Active + Bind. Unbound and Active means always
  on; a bind makes it a hold-to-brighten key.
- Applies the client `mat_fullbright` cvar to 1/0 through
  `IVEngineClient::ClientCmd` (slot 7), which bypasses the console cheat gate
  and avoids the crashing direct `ConVar` vtable path. No server-side writes.
- Re-applied after map loads/respawns (`fullbright_invalidate` on local entity
  change) and saved as `[fullbright] active/bind`.
- `fullbright: on/off` is logged; a missing cvar interface raises an error
  toast instead of failing silently.

### World tint (material colour modulation)

- Same idea as Amalgam's world modulation: find `IMaterialSystem` in the
  already-loaded `materialsystem.so` and walk every material with
  `FirstMaterial` / `NextMaterial` / `GetMaterial`.
- `IMaterial::ColorModulate(r,g,b)` is applied to materials whose
  `GetTextureGroupName()` is `World textures` (sky names excluded), to
  `SkyBox textures` (or names containing `sky`), and to prop materials whose
  name starts with `models/props` (static props and prop_physics share those
  model materials; NPC/player/weapon materials are excluded). Lightmaps are
  not touched.
- Slot map (2013 SDK layout, registered under `VMaterialSystem080`):
  `IMaterialSystem` 73/74/75/76/77 = First/Next/Invalid/Get/GetNumMaterials;
  `IMaterial` 0/1/28 = GetName/GetTextureGroupName/ColorModulate.
- `src/world.cpp`; the walk runs only when the enabled groups or colours
  change, and re-applies after level changes via `world_tint_invalidate()`.
  Disabling restores white, so no reload is needed.
- Validation before any walk: `GetNumMaterials()` must return 1..200000; the
  first material's name/group must be printable strings. A mismatch logs and
  toasts instead of poking garbage.
- Saved as `[worldtint] active/bind/world/sky/prop/wr..pb`.
- Particle tint is deliberately not implemented: HL2's old particle manager
  draws per-vertex coloured IMesh quads, and SpriteCard/TF2-style particles
  take colour from per-particle vertex data, so `IMaterial::ColorModulate`
  does nothing for them. It needs a hook in the client particle render path.

### Fog

- `src/world.cpp` `fog_update()`, driven by the `fog_override` engine command
  family through `IVEngineClient::ClientCmd` (same safe path as fullbright).
  No render hooks.
- Enabled: `fog_override 1`, `fog_enable 1`, `fog_color`, `fog_start`,
  `fog_end`, plus `fog_enableskybox` / `fog_colorskybox` /
  `fog_startskybox` / `fog_endskybox` when skybox fog is on.
  "Remove fog" issues `fog_enable 0` instead.
- Disabled: `fog_override 0` restores the map's own fog controller.
- End is clamped above Start. Re-applied on level change via
  `fog_invalidate()`. Saved as `[fog] active/bind/r/g/b/start/end/skybox/disable`.

## Logging

- `/tmp/hl2hake.log` is opened once at startup with `"w"` (so it only contains
  the current session), line-buffered, and closed via `atexit`.
- `logf` is always on: lifecycle, warnings/errors, config, GUI open/close and
  item-crate detections.
- `logd` is off unless `HL2H_DEBUG=1`; it carries per-scan ESP/NPC/item-prop,
  aimbot-target, bone and netvar noise. Run `HL2H_DEBUG=1 ./play.sh` to enable.

## Gotchas / lessons

- Never pipe-mask build failures:
  `./build.sh > /tmp/build.log 2>&1; echo rc=$?`.
- `CHLClient` slot numbers: `CreateMove` 21, `FrameStageNotify` 35.
- Networked EHANDLE values use an 11-bit entity index in the low bits; bits
  11+ are the serial. Mask with `0x7FF`, not the local `CBaseHandle` 16-bit
  mask. This caused active-weapon lookups to produce bogus indices after a
  map change once the weapon serial was nonzero (melee killbot looked dead).
- `ISurface` binary slot = SDK header slot + 4; see `src/ISURFACE_STATUS.md`.
- GUI font handles are validated with `GetTextSize` and recreated when the
  local player entity or UI scale changes; if the scaled size fails, 16 px is
  used as a fallback. VGUI can silently invalidate font handles across level
  transitions.
- Silent aim and ABH temporarily replace the usercmd view; silent aim preserves
  movement, ABH intentionally zeroes it for the jump tick.
- Aimbot/silent/melee vischeck now traces everything bullets care about
  (`MASK_SHOT`) while ignoring the local player and target. Static props,
  crates and other NPCs block. Non-solid glass and breakable-glass materials
  are skipped; solid non-break glass (bulletproof) blocks. Triggerbot still
  uses the same `MASK_SHOT` trace and will not fire through glass.

## Valuable items

- Visuals feature; Active is on by default and unbound (always-on).
- Scans the client entity list every 100 ms and tags health, ammo, armor/suit,
  world weapon pickups, the two supply-crate models and generic items.
- Each tag is a colored marker with a name and optional distance, filtered by
  category toggles and a max-distance slider (default 5000 units; 0 means
  unlimited and displays as "All"). Drawn through walls and independently of
  the hostile NPC ESP.
- Ammo labels are specific where the model is known: pistol, SMG, AR2, .357,
  crossbow, shotgun, sniper, flares, AR2 grenade, RPG rocket, plus ammo-crate
  variants. Unknown ammo falls back to "Ammo".
- Weapons are world pickups only: the active weapon and anything with a valid
  `m_hOwner`/`m_hOwnerEntity` handle is skipped.
- Only `item_item_crate` and `item_beacon_crate` are tagged as supply crates.
  Other breakable crates are intentionally ignored; crate chunk debris is
  skipped.
- Client `CPhysicsProp` entities often have `m_nModelIndex == 0`, including
  item crates. The item scan falls back to the server entity's model index
  (`server_model_index`) to recover the model name.

## Roadmap / TODO

- **Server-side spread/recoil control**: not planned; recoil and accuracy
  penalties are intentionally left stock.
- **Distance weapon spread thesis**: addressed by Spread control; stock HL2
  hitscan damage is range-independent, so distance efficiency is the cone.