From 33f5ed9ef58f26c6b3b52cf605dd4311d658a60d Mon Sep 17 00:00:00 2001 From: salmonik Date: Fri, 6 Nov 2026 16:09:55 +0100 Subject: Fix audit findings: crash safety, correctness, rendering, performance Crash/UB: - gate every hook behind an init-stage flag until config/surface/input are ready - publish module maps as immutable atomic snapshots instead of reallocating shared vectors under unlocked readers - validate the %ebp walk in the RandomSeed/RandomFloat hooks against the stack - bounds-check every RecvTable/RecvProp/string dereference with module_range() - preserve the Itanium vtable header when cloning (client, CInput, model render, studio render) - reject negative usercmd sequence numbers; guard config_load against a null section target; cap the bone parent walk; check mprotect; drop PROT_EXEC on the GOT patch; absorb server.so .bss tail Correctness: - filter dormant entities across ESP, aimbot, triggerbot, melee, items and projectiles (slot self-tested against the local player) - reset the server-health latch per map and only latch on valid model indices - bone cache sized for the 128-target scan; set valid=false on every failure; head_position keeps searching past an invalid bone - vischeck steps past glass by probing until clear, and ignores entities owned by the target (held weapons, bone followers) - aimbot: maxs no longer disables it, AABB-aware FOV prefilter, lock hysteresis, lead point re-checked for FOV and visibility - spread: seed hook arms on the no-spread toggle alone; engine toggle only controls the RandomFloat zeroing - resolve m_hOwnerEntity/m_hOwner; NPC lead velocity from the server entity - vector_angles pitch range, point_ground normal test, underwater jump+duck no longer skips combat, semi-auto press/release edge for synthetic fire - item/projectile markers and blast rings refresh origin per frame while discovery stays at 10 Hz; trajectory uses the real m_DmgRadius and honours the blast-radius toggle Rendering/GUI: - one overlay draw per engine frame (FRAME_RENDER_START boundary), fixing alpha stacking and the speed-graph flicker - clipped lines/outlines and ellipsised text instead of drawing past the content pane or shifting over neighbours - dropdown dismissal consumes the click; hover ignores hidden controls - chams: reset AlphaModulate, reference shared materials once, restore prop tint colour; fonts created once per handle - projection uses Source's 4:3 aspect scaling and roll; screen size refreshed each frame; FOV circle gated on the aim toggles, not the bind Performance: - TTL caches for NPC relation, friendly/neutral and damage class - model names memoised per model index - entity pointer -> index cache for the triggerbot (was a 4096-slot walk/tick) Build: - real Makefile with -MMD dependency tracking, debug/release objects and an always-relink rule; build.sh kept as a thin wrapper --- .gitignore | 1 + FIX_PLAN.md | 802 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ Makefile | 60 +++++ build.sh | 16 +- src/bones.cpp | 36 ++- src/cinput.cpp | 39 ++- src/esp.cpp | 582 ++++++++++++++++++++++++++++++---------- src/gui.cpp | 259 +++++++++++------- src/hl2h.cpp | 214 +++++++++++---- src/hl2h.h | 50 +++- src/menu.cpp | 99 +++---- src/modules.cpp | 47 +++- src/netvars.cpp | 50 ++-- src/overlay.cpp | 140 ++++++++++ src/overlay.h | 62 +++++ src/server.cpp | 27 +- src/studio.cpp | 71 +++-- src/vischeck.cpp | 62 ++++- src/world.cpp | 45 +++- 19 files changed, 2217 insertions(+), 445 deletions(-) create mode 100644 FIX_PLAN.md create mode 100644 Makefile create mode 100644 src/overlay.cpp create mode 100644 src/overlay.h diff --git a/.gitignore b/.gitignore index 1239075..44e3233 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ libhl2hake.so a.out crash +build/ # vendored SDK + reference cheats (read-only material) source-sdk-2013/ Amalgam/ diff --git a/FIX_PLAN.md b/FIX_PLAN.md new file mode 100644 index 0000000..e4e149e --- /dev/null +++ b/FIX_PLAN.md @@ -0,0 +1,802 @@ +# hl2h fix plan + +Derived from a full audit of `src/` (12,243 lines) plus `build.sh` / `play.sh`. +Every item below was re-verified against the working tree; claims that did not +survive verification are listed in [§6](#6-verified-not-a-bug--do-not-fix) so +nobody wastes time on them. + +Ordering: **P0 → P1 → P2 → P3**. Each phase leaves the cheat playable. +Work inside a phase is independent enough to parallelise; cross-phase +dependencies are noted. + +--- + +## 0. Verification ground rules + +- Line numbers are from the audited tree; re-check after edits. +- "Fix" means *smallest change that removes the failure mode*, not a rewrite. + Rewrites belong to P3. +- Every P0/P1 item must be reproducible before the fix and re-checked after. + For logic fixes, extract the function into `tests/` (see §5) rather than + relying on eyeballing a running game. +- Anything touching hook install/uninstall goes in with a readiness guard + (P0-1), otherwise later phases regress the race. + +--- + +## 1. P0 — crash, UB, and startup races + +These are the items that produce the existing `crash` log and the highest risk +of nondeterministic hangs. Do them first, in this order. + +### P0-1 Hook readiness gate and init ordering *(M)* +**Where:** `hl2h.cpp:1185` (`chams_init`), `:1204-1206` (`init_conmsg`, +`install_hook`, `install_menu`), `menu.cpp:1224` (`config_load`), +`menu.cpp:1229` (ISurface discovery). + +**Problem:** `CHLClient` and `CInput` hooks are installed *before* config load +and ISurface discovery. The game thread can enter `hkdCreateMove` / +`hkdInputCreateMove` / `hkdModelExecute` while `setup_thread` is still writing +feature globals and `g_surface`. Every hook is also installed lazily from +different threads (surface hooks from `CreateMove`, studio-prop hooks from the +paint path), so "is init done" is not observable. + +**Fix:** +1. Add `std::atomic g_init_stage {0}` (`0` = nothing, `1` = interfaces + + netvars, `2` = config + surface + fonts, `3` = hooks live). +2. Move `install_hook()`, `install_input_hook()`, and the seed/uniform patches + to *after* `install_menu()` completes. +3. Every hook entry point starts with + `if (g_init_stage.load(std::memory_order_acquire) < 3) return call_original(...);` + — hooks stay installed, they just pass through until ready. +4. Keep lazy surface/studio installs, but have them check the stage instead of + relying on "first `CreateMove` implies ready". +5. On any setup failure, set stage to a terminal `-1` and log once; hooks then + pass through forever instead of half-working. + +**Verify:** start the game straight into a map (`+map`) and into the main menu, +10× each, with `HL2H_DEBUG=1`; `hook installed:` lines must now appear after +`config loaded:` / `surface resolved:`. No crash in `crash`-style backtraces. + +--- + +### P0-2 Module snapshots are published under a lock but read unlocked *(M)* +**Where:** writer `modules.cpp:78-84`; readers `hl2h.h:47-52` +(`Module::contains` / `valid` / `.ranges` / `.path`), `netvars.cpp:9`, +`server.cpp:71,95,147`, `hl2h.cpp:1104-1109`. Re-entry: `server.cpp:147-148` +calls `find_modules()` from the game thread while `setup_thread` reads +`g_client`/`g_engine`. + +**Problem:** `find_modules()` move-assigns `g_client`/`g_engine`/`g_server`, +freeing `std::vector` and `std::string` buffers while another thread +iterates them. Locking only the writer is worse than no lock — it hides the +race. + +**Fix:** +1. Make `Module` immutable after publish. Keep a + `std::shared_ptr` and publish with + `std::atomic_store(&g_modules, new_set)` (C++11 `shared_ptr` atomic free + functions; C++17 build is fine). +2. Readers do `auto m = std::atomic_load(&g_modules);` once per function and + use `m->client` for the whole body. No lock, no torn state. +3. Stop calling `find_modules()` from `server.cpp` / `netvars.cpp` on demand. + Resolve once during init; on failure, retry from the setup thread only. + If a late retry is genuinely needed, it must publish a new snapshot, never + mutate the current one. + +**Verify:** run `resolve_server_netvars` with a deliberate 100 ms delay under +`-fsanitize=thread` on a native (non-`-m32`) build of `modules.cpp` only if +practical; otherwise add an assertion that `find_modules` is only called from +the setup thread. + +--- + +### P0-3 `%ebp` frame-pointer dereference in the random hooks *(M)* +**Where:** `hl2h.cpp:497-501` (`hkdSrvRandomSeed`) and `:606-610` +(`hkdUniformRandomFloat`). `build.sh` has `-fno-omit-frame-pointer`, but that +only constrains *this* `.so`; the callers are Valve's `server.so` and +`libvstdlib.so`. + +**Problem:** `frame = *(void **)ebp; self = *(void **)(frame + 8)` assumes a +standard frame chain and a first-argument-at-`+8` layout in code we do not +control. A non-frame-pointer caller, tail call, or different convention +segfaults immediately. + +**Fix (in order of preference):** +1. Drop the `self` derivation entirely. `g_spread_rand_left` only needs to be + armed while the *local player* fires; `hkdSrvRandomSeed` already knows the + seed window is being requested from the local server entity path + (`g_local_server_player` is available via `server_active_weapon`-style + lookups). Arm the window on `CreateMove` when `IN_ATTACK` is set and the + active weapon has spread, then let the hook only substitute the seed. +2. If the frame walk stays, hard-validate before both dereferences: + `contains_range(ebp, 4) && (ebp & 3) == 0`, same for `frame` and + `frame + 8`, and require the resulting `self` to be inside a mapped module + or the entity-list heap range. Bail to the original on any failure. +3. Never leave `g_spread_rand_left` armed across ticks: reset it in + `do_move` entry and on `CreateMove` return. + +**Verify:** pistols/SMG/AR2/shotgun/crossbow, 20 shots each, log +`seed in / seed out / fallback` counters; zero fallbacks on a normal map, no +crash on maps with scripted weapons. + +--- + +### P0-4 Netvar scan bounds validation *(S)* +**Where:** `netvars.cpp:9-19` (`module_string` checks only the first byte), +`:33-39` (derefs `table+0`, `table+4`, `table+0xC` before validating `table`), +`:317` (root pointer from `cc+0xC` unvalidated), plus the nested `ptable` path. + +**Fix:** add one helper next to `Module`: +```cpp +bool module_range(uintptr_t p, size_t n); // p and p+n-1 inside one range +``` +Use it for every pointer before the first dereference and for every string +scan (`module_range(p, outn)`), then keep the existing printable-char check. +Add an `assert(g_client.contains(table))`-style precondition in +`collect_netvars` so future callers fail loudly instead of reading garbage. + +**Verify:** `resolve_netvars` logs "resolved N/M" on main menu, map load, and +map change; N/M must not change. No crash when `server.so` is absent. + +--- + +### P0-5 Preserve the Itanium vtable header when hooking *(S)* +**Where:** `hl2h.cpp:1060-1068`, `cinput.cpp:153-162`, +`studio.cpp:544-548`, `studio.cpp:584-598`. + +**Problem:** all four sites do `new void*[N]; memcpy(new, vt, N*4); ptr = new;` +so `new[-1]` (typeinfo) and `new[-2]` (offset-to-top) are heap metadata. +The cheat never calls RTTI on these pointers, so this is currently latent, but +the engine may, and the fix costs nothing. + +**Fix:** allocate `N+2`, set `dst[0]=vt[-2]; dst[1]=vt[-1];` and memcpy the +slots at `dst+2`; hand out `dst+2`. Add a tiny helper +`void** clone_vtable(void** vt, size_t slots)` used by all four sites — this +also removes the duplicated 256/32/49 magic sizes. + +**Verify:** hooks still fire (`logd` in each handler); no behaviour change +expected. Optional: `nm`/`objdump` sanity that the new header words match. + +--- + +### P0-6 `config_load` null deref on malformed config *(S)* +**Where:** `menu.cpp:347-385` sets `cur = nullptr` for `[view]`, +`[movement]`, `[chams_*]`; `menu.cpp:643-646` dereferences it unconditionally. + +**Reachability:** `config_save` never writes `active`/`bind` there, so this is +hand-edited/corrupt-file only. Still a one-line crash. + +**Fix:** the section loop should carry an explicit "target kind" enum +(`TARGET_FEATURE`, `TARGET_SCALARS`, `TARGET_CHAMS`, `TARGET_IGNORE`) and the +key handler should switch on it, not on `cur`. Minimum change: +`if (!cur) continue;` before the `active`/`bind` branches. + +**Verify:** add the malformed cases to `tests/` (§5) — `[view]\nactive=1`, +`[chams_vm]\nbind=3`, truncated last line, no trailing newline. + +--- + +### P0-7 Signed modulo on `seq` in `cinput.cpp` *(S)* +**Where:** `cinput.cpp:61`, `:72-73`, `:141`. + +**Problem:** `seq % 90` is negative for negative `seq`; `(size_t)` then makes a +huge index. The `command_number` sanity check happens *after* the pointer is +formed and dereferenced. + +**Fix:** `if (seq < 0) return;` at the top of `usercmd_get` / +`fix_verified_crc` / `hkdInputCreateMove`, and use +`((unsigned)seq) % 90u` throughout. + +**Verify:** assertion `seq >= 0`; no behaviour change in normal play. + +--- + +### P0-8 Defensive guards with real (if unlikely) triggers *(S, batch)* +Small, independent, cheap: + +| Item | Where | Fix | +|---|---|---| +| Unbounded parent walk | `bones.cpp:419-423` | 64-iteration cap, same as the humanoid branch | +| `mprotect` unchecked, `PROT_EXEC` on data, unaligned slot | `hl2h.cpp:571-573`, `:646-651` | check return; touch the full page range spanning the slot; `PROT_READ\|PROT_WRITE` only; restore original prot after the write | +| `server.so` `.bss` tail not absorbed | `modules.cpp:50-58` | add the `server` branch alongside client/engine | +| studio `name[64]` read as 128 | `studio.cpp:64-70` | `strnlen(name, 64)`; note this reads adjacent header fields, not OOB memory, so it is a correctness fix only | +| `bone_nearest_core_parent` / `build_core_mask` recomputed every tick | `bones.cpp:346-362` | recompute only when `hdr`/`count` changes | + +**Verify:** map with many NPCs (e.g. `d2_coast_03`), skeleton ESP on, 10 min; +`bones_update` budget logged once per second must stay flat. + +--- + +## 2. P1 — gameplay and data-correctness bugs + +### P1-1 Dormant-entity handling *(M)* +**Where:** none of `esp_collect` (`esp.cpp:436`), `select_aim_target` (`:916`), +`melee_aimbot_apply` (`:1375`), `triggerbot_apply` (`:1732`), +`items_collect` (`:2740`, `:2801`), `projs_collect` (`:3090`), +`prop_tint_update` (`:1797`) checks `IsDormant()`. + +**Fix:** one helper `bool entity_dormant(void* ent)` in the entity-query layer +(P3 target: `entity_query.cpp`), plus a call at the top of each loop. For +aimbot/triggerbot/melee this is a correctness fix (stale frozen positions); +for items/projectiles it is a ghost-marker fix. Do **not** add it to +`bones_update` alone — gate at collection so downstream code never sees them +(also removes the "SetupBones on a dormant entity" class of crash). + +**Verify:** walk out of an NPC's PVS; box/arrow/skeleton disappears within one +tick, aimbot stops acquiring it, and no `schema`/studio asserts appear in +console. + +### P1-2 `srv_checked` permanent latch *(S)* +**Where:** `esp.cpp:433-434`, `:459-464`. Reset block exists at +`hl2h.cpp:784-793` but does not touch it. + +**Fix:** move the two statics into the map-change reset registry (P3-4; a +temporary `esp_reset_session()` called from `hl2h.cpp:784` is enough now), and +only latch when `cli_mi > 0 && srv_mi > 0`. Keep re-checking per NPC for the +first ~30 s after a map load rather than once ever. + +**Verify:** load map → immediately look at an NPC before it finishes spawning +→ health bar must appear once the model index syncs. + +### P1-3 Bone cache correctness *(M)* +**Where:** `bones.cpp:103` (`g_entries[64]`), `:121-125` (`g_next++ % 64`), +`:322-330` (early returns leave `valid == true`), `:494-497` +(`head_position` returns on the first name match even if invalid). + +**Fix:** +1. Size the cache for the real upper bound (128 targets) with headroom: 256 + entries, LRU by stamp, or a `std::unordered_map` keyed by + entity index. Never evict an entry whose `stamp` is the current tick. +2. Set `e->valid = false` on *every* failure path, including `:322-330`. +3. `head_position`: `continue` instead of `return` when `hitbox_world` / + `bone_pos` fails, so the fallback chain still runs. +4. Make `bones_update` return the cached entry pointer so callers cannot read + a different (evicted) entry than the one that was validated. + +**Verify:** map with >64 NPCs (or a `npc_create` stress test); skeleton ESP +must not flicker; `bones_count` must never return a count for an entity whose +`SetupBones` failed this tick. + +### P1-4 Vischeck: glass stepping and the entity filter *(M)* +**Where:** `vischeck.cpp:179-214` (step 0.5 past `tr.endpos`), `:189-190` +(`startsolid` → `false`), `:91-99` (`ShouldHitEntity` ignores only `self` and +`target`). + +**Fix:** +1. Replace the fixed 0.5 step with "advance until outside the brush, up to + N units, then continue", i.e. loop the trace forward while `startsolid` or + the hit entity is still the same glass brush, capped at, say, 32 units and + never past `point`. +2. `ShouldHitEntity` must also ignore the target's children: resolve + `m_hOwnerEntity` (needs P1-7) and skip anything whose owner is `target`; + that covers held weapons and `phys_bone_follower`. +3. Early-out when `start` is within epsilon of `point` instead of tracing + backwards. + +**Verify:** shoot through `func_breakable` glass thick and thin, strider and +gunship hitboxes, and a target holding a weapon — ESP visibility and aimbot +vischeck must agree with manual LOS. + +### P1-5 Aimbot selection and firing *(M)* +**Where:** `esp.cpp:815-816` (hard `maxs < 0`), `:943-945` (fallback exists), +`:949-953` (single `approx` prefilter), `:887` (lock with `enforce_fov=false`), +`:1079-1084` (lead applied after vischeck/FOV), `:1199`, `:1532`, `:1751` +(unconditional `IN_ATTACK`). + +**Fix:** +1. Delete `g_net.maxs < 0` from the gate at `:815`; rely on the + `maxz = ... : 72.0f` fallback. +2. Prefilter: use a capsule/segment from the entity's `origin.z` to + `origin.z + maxz` and test distance to the view ray, or simply widen + `max_fov` by the angular radius of the AABB (`atan(maxRadius/dist)`) before + rejecting. Then keep the exact per-hitbox test in `try_target`. +3. Lock: keep the lock but re-apply the FOV test with hysteresis (e.g. allow + up to `max_fov * 1.5` before dropping), and key the lock on + `(entindex, serial)` from `CBaseHandle`/`entindex` so slot reuse cannot + transfer it. +4. Lead: compute `lead` first, then run vischeck and FOV on `lead`. If the + lead point fails, fall back to the un-led point rather than firing at a + wall. +5. Fire control: track the tick of the last synthetic `IN_ATTACK` and clear + the button for one tick after each shot (a small state machine per + weapon), or drive `IN_ATTACK` from `m_afButtonPressed` semantics so + semi-autos see a release edge. + +**Verify:** per weapon class (pistol, SMG, AR2, shotgun, crossbow, RPG) run +10-shot strings at short/medium/long range, log chosen target id, hitbox, and +whether FOV/vischeck passed on the *led* point. + +### P1-6 Spread control gate *(S)* +**Where:** `hl2h.cpp:505-507` and `:601-602` require +`spread_no_spread_enabled() && spread_engine_enabled()`; `esp.cpp:1659` only +requires `no_spread`. + +**Problem:** turning off "Engine spread override" disables the seed hook +entirely, so "seed selection only" does nothing (documented at +`hl2h.cpp:474`). + +**Fix:** decouple the two gates: the seed-substitution hook is armed by +`spread_no_spread_enabled()`; `spread_engine_enabled()` only controls the +`RandomFloat`→0 override. Update the GUI tooltip text if the semantics change. + +**Verify:** with engine override off, 20 shotgun blasts: pellet grouping must +be tight and repeatable. + +### P1-7 Resolve `m_hOwnerEntity` / `m_hOwner` *(S)* +**Where:** `netvars.cpp` has no branch for either; `g_net.owner` / +`g_net.weapon_owner` stay `-1` (`hl2h.h:99-100`) yet are read at +`esp.cpp:2253-2259`, `:2272-2275`. + +**Fix:** add both name matches in the netvar walk (with the P0-4 range checks), +then use them for (a) the vischeck filter in P1-4, (b) weapon ownership in +`items_collect`. + +**Verify:** item ESP must stop marking freshly dropped weapons as owned; +vischeck must stop being blocked by held weapons. + +### P1-8 Math and movement correctness *(S)* +| Item | Where | Fix | +|---|---|---| +| `vector_angles` pitch wraps to `[0,360)` | `hl2h.cpp:90-94` | return `[-90,90]`; normalise yaw to `[-180,180]`; delete the `.x = 0` workaround at `:865` | +| Early `return` skips all features underwater | `hl2h.cpp:855-859` | set the underwater flag and `goto`/`break` past only the bhop section; never skip `note_modified()` | +| `point_ground` ignores surface normal | `vischeck.cpp:273` | require `tr.normal.z >= 0.7f` like `ground_distance` (`:291`) | +| `jb_force_next_distance` casts away `const`, cached `vz` goes stale | `hl2h.cpp:286-304`, `:334`, `:459` | drop the cached `vz`, re-read `vel[2]` after the call, or return the new value | +| melee AABB has no lower bounds | `esp.cpp:1420-1431` | reject `minx/miny/minz` outside `±512` | +| triggerbot O(4096) index scan | `esp.cpp:1729-1758` | use `IClientNetworkable::entindex()` on the hit entity | + +**Verify:** underwater jump+duck still bhops and still shoots; edge-jump at a +steep-slope ledge; aimbot aims correctly when looking up (pitch sign). + +### P1-9 Cache/table lifetime bugs *(S)* +**Where:** `esp.cpp:3060-3073` (`proj_first_seen` 32 fixed slots, no +eviction); `esp.cpp:2719-2774` (`owned_mask` OR'd within a map). + +**Fix:** replace `proj_first_seen` with a 64-entry ring keyed by +`(entindex, serial)` that overwrites the oldest entry; recompute `owned_mask` +from `server_player_weapon(local_idx, slot)` on every scan when the server +interface is available (keep the OR as fallback only). + +**Verify:** throw >40 grenades on one map; fuse timers must stay correct. Drop +and re-pick a weapon; item ESP must reclassify it. + +### P1-10 Projectile lead source *(S)* +**Where:** `esp.cpp:1045-1048` reads `C_BaseEntity::m_vecVelocity` on NPC +targets, but Source only networks that field for the player +(`baseentity.cpp:6665`), so NPC lead velocity is stale/zero. +`g_srv_velocity` is resolved at `server.cpp:161` and never used. + +**Fix:** for NPC targets, read velocity through the server entity path +(`g_srv_velocity`) and fall back to zero lead on failure. Grenades +(`esp.cpp:3185-3187`) are fine — they do network velocity. + +**Verify:** crossbow/RPG lead on a moving combine at range; the led point +should lead the NPC's actual movement. + +### P1-11 Visual toggles gated by the wrong feature *(S)* +- `draw_fov_circle` is inside `esp_draw` (`esp.cpp:2084`, gated by + `FEAT_ESP` at `menu.cpp:1083`) so it shows with aimbot off and hides with + ESP off. Move the call to a neutral draw path or gate it on + `aimbot_active || silentaim_active`. +- `draw_transitions` (`esp.cpp:2092`) has no setting at all. Add a toggle and + default it off (its `transitions_scan` walks server entities every frame). +- `draw_fov_circle` rejects `fov >= 89` (`esp.cpp:606`) while the slider + allows 180 (`gui.cpp:371`). Clamp the slider to the drawable range, or + render the cone as a screen-space wedge instead of projecting axis points. + +**Verify:** toggle ESP off with aimbot on → circle stays; toggle aimbot off +with ESP on → circle gone; transitions toggle actually stops the wireframes. + +--- + +## 3. P2 — rendering and GUI correctness + +### P2-1 Draw exactly once per frame *(M)* +**Where:** `hl2h.cpp:1008-1032`; guard is only `g_in_draw` (`:1003`), which +prevents nesting, not repetition. Both `hkdDrawPrintText` (`:1017`) and +`hkdPaintTraverseEx` (`:1027-1031`) call `outer_draw()`. + +**Fix:** pick one owner. Preferred: `PaintTraverseEx` filtered to +`MatSystemTopPanel` (or the engine's root panel), and use `DrawPrintText` +only as a fallback if no paint happened in the current frame +(`last_paint_frame != g_frame_count`). Add a per-frame id so the guard is +"already drew this frame", not "currently drawing". + +**Secondary (independent):** save and restore ISurface text state around +`outer_draw()` — `m_hCurrentFont` (`this+0x290`), `drawColor` (`+0x24`), +`textColor` (`+0x28`), `m_nTextX/Y` (`+0x2c/0x30`) — because +`PushMakeCurrent`/`PopMakeCurrent` only unwinds the clip stack +(`ISURFACE_STATUS.md:64-67`). + +**Verify:** alpha-255 world tint and a 120-alpha ESP box must look identical +whether ESP is drawn once or with the old code; instrument a frame counter and +assert exactly one `outer_draw` per frame. + +### P2-2 Single source of truth for widget geometry *(S now, folds into P3-2)* +**Where:** `gui.cpp:1347-1351` (`cr.x` includes `+S(10)`), +`:1948` and `:1627` (track from `cr.x`), `:1854-1855` (drag range omits the +`+S(10)` and is 10 px wider at the right edge). + +**Immediate fix:** compute the track rect once in a helper +`slider_track(const Rect& cr)` and call it from `draw_slider`, +`handle_mouse_click`, and the drag handler. Add the missing `+S(10)` in the +drag range as a one-line stopgap if the helper is deferred. + +**Verify:** click a slider knob at the far left/right, drag across; value must +track the cursor 1:1 and not jump on mouse-down. + +### P2-3 Clipping correctness *(S)* +| Bug | Where | Fix | +|---|---|---| +| `line()` has no clip | `gui.cpp:920` | add `const Rect* clip` and clip via the same Cohen–Sutherland helper used for rects; use it for the checkbox checkmark (`:1941-1942`) and picker cursors (`:1300-1301`, `:1313-1314`) | +| `outline()` draws a border on the clip edge | `gui.cpp:911-918` | clip each of the four edges independently instead of outlining the clamped rect | +| `text_in()` shifts text left instead of clipping | `gui.cpp:872-888` | honour `max_w`: binary-search the longest prefix that fits, append `…`; never move `x` left | + +**Verify:** scroll a card halfway past the content top/bottom — no border line +through the card, no checkmark floating in the header, long dropdown labels +truncate instead of overlapping the label column. + +### P2-4 Pointer/click discipline in the GUI *(S)* +**Where:** `gui.cpp:1501-1522` (dropdown dismissal falls through), +`:1717-1772` (`handle_hover` ignores `g_dropdown_ctrl`). + +**Fix:** a single `consume_click()` result from the dropdown block; when it +closes, return immediately. `handle_hover` early-outs while a dropdown, colour +picker drag, or slider drag is active. Also close pickers/dropdowns on +outside-click consistently (currently pickers need another colour control). + +**Verify:** open a dropdown over a toggle row and click the toggle; the +dropdown must close and the toggle must not flip. + +### P2-5 SDL interposition state tracking *(M)* +**Where:** `gui.cpp:140-141` (`dlsym(RTLD_DEFAULT, ...)` resolves the cheat's +own exported wrapper), `:2415-2434` (requests dropped while open), +`:1382-1385` (restores the pre-open snapshot), `:1410-1412` (snapshot). + +**Fix:** +1. Resolve real symbols with `dlsym(RTLD_NEXT, ...)` in both wrappers (they + already do internally at `:2417`/`:2430`; the public `sd_*` pointers must + match). +2. Track *last requested* state while open: in the wrappers, record + `g_game_wants_cursor` / `g_game_wants_relative` before forcing the GUI + state, and restore those on close instead of the pre-open snapshot. +3. Don't drop all `SDL_MOUSEMOTION`; drop only the relative-motion deltas the + game would consume, or key off `g_open` + "not over a GUI window". +4. Keep the `MOUSEWHEEL` watch but honour `direction == FLIPPED` + (`SDL_MouseWheelEvent` at `+24`). + +**Verify:** open GUI on the main menu → load a map → close GUI; mouse look must +work and the cursor must hide. Same sequence with alt-tab in the middle. + +### P2-6 Chams material state *(M)* +**Where:** `studio.cpp:267` (AlphaModulate set), `:282-291` (`chams_reset` +never resets it), `:350-384` (forced override cleared for every pass, +including shadow depth), `:494-510` (same `IMaterial*` refcounted 2–4×), +`:122-131` (`hook_draw_model` never restores the previous modulation). + +**Fix:** save the previous material state (colour modulation, `$color`, +`$alpha`, `AlphaModulate`) and restore it in `chams_reset`; dedupe material +names in `chams_preload` (one `IncrementReferenceCount` per unique material); +skip the override when the current pass is a shadow-depth draw (compare the +engine's existing forced override, or check the draw flags before clearing); +restore `SetColorModulation` after `hook_draw_model`. + +**Verify:** chams on/off repeatedly with a flashlight/shadow-casting map; +shadows must not disappear; translucent chams must not stay semi-transparent +after toggling off; static prop tint must not bleed onto other models. + +### P2-7 Font lifecycle *(S)* +**Where:** `gui.cpp:687-703` (create inside the family loop), +`:704-713`/`:725-727` (reset on level/scale change), `menu.cpp:1107-1131` +(same pattern). + +**Fix:** create once per `(scale, DPI, font file)` and keep the handles; try +families by `AddGlyphSetToFont` on one handle where possible. Level changes +must not reset fonts at all (VGUI fonts are global). On scale change, +recreate once and log; accept the bounded leak because `ISurface` has no +`DestroyFont`. + +**Verify:** die/respawn 20× and change UI scale 5×; log the `HFont` counter — +it must not grow per level change. + +### P2-8 Projection and resolution *(M)* +**Where:** `esp.cpp:57-79` (`project_manual` ignores the +aspect/(4/3) scaling Source applies at `view.cpp:1075-1083`, and ignores +roll), `:2078`, `:2925`, `:3301` (screen size cached once). + +**Fix:** +1. Prefer `IVDebugOverlay::ScreenPosition` everywhere; keep manual projection + only as a fallback, and in the fallback apply + `tanHalfH *= aspect / (4.0f/3.0f)` and include the roll term from + `g_cam_view.z`. +2. Refresh `g_scr_w`/`g_scr_h` every frame (or on `GetScreenSize` change) — + mirror what `gui.cpp:928-930` already does. +3. Don't mix `world_to_screen` and `project_manual` across one segment + (`esp.cpp:2006-2009`); pick one per segment. + +**Verify:** 16:9, 16:10, 21:9, and windowed resize mid-session: blast rings and +trajectory arcs must stay glued to the world; no kink at the near plane. + +### P2-9 Skybox lifetime *(M)* +**Where:** `world.cpp:752-761` (in-place HDR linearisation), `:953-959` +(`skybox_invalidate` keeps texture + pixels), `:699-705`/`:720-729` +(originals stored without refcount), `:961-968` (`g_sky_tex = nullptr` leaks +the procedural texture). + +**Fix:** keep a pristine copy (`g_sky_pixels_src`) and rebuild the working +buffer on each apply; `AddRef` originals when recorded and release on +invalidate; detach the regenerator and `DecrementReferenceCount` the old +procedural texture on reload. Either implement the documented +`SetUndefined()`/`Refresh()` path or update `CONTEXT.md:633-635` — right now +the docs describe code that does not exist. + +**Verify:** HDR map → LDR map → HDR map; sky brightness must reset each time. +No material-system assert on map change with a custom skybox. + +--- + +## 4. P3 — architecture + +Do these only after P0/P1 are green. Each is behaviour-preserving and can land +independently. Order is by bug-surface reduction per unit of risk. + +### P3-1 Overlay layer: one ISurface wrapper *(M)* +`menu.cpp` (`SURF_*`/`surf_*`), `esp.cpp` (`SurfSlot`/`surf_*`), `gui.cpp` +(`GS_*`/`s_*`) each hardcode the same slots. Introduce `overlay.h/.cpp` with +the slot table, `surface_*` primitives, text drawing, clip handling, and the +`outer_draw()` dispatcher. Everything else calls it. This also gives P2-3/P2-1 +one place to fix. + +### P3-2 Declarative settings table *(M)* +Replace the current six-place edit (header extern, definition, +`config_save`, `config_load`, `config_reset_defaults`, `build_cards`) with one +table: +```cpp +struct SettingDesc { const char* section; const char* key; Type type; + void* ptr; double def, min, max; const char* label; }; +``` +Generate save/load/reset/clamp from it; `build_cards` consumes the same +descriptors. Eliminates P0-6 and the duplicated clamp bounds, and makes new +options cheap. + +### P3-3 Split `esp.cpp` *(L, mechanical)* +Target files, keeping current call graph: `entity_query.cpp` (class names, NPC +relation, viewmodel/owned-weapon helpers — also the home for `entity_dormant` +from P1-1), `combat.cpp` (aimbot/silent/triggerbot/melee/spread search), +`esp.cpp` (boxes/skeleton/arrows/speed graph/transitions), `items.cpp`, +`projectiles.cpp`. No behaviour change; move code verbatim, then fix includes. + +### P3-4 Session-reset registry *(S)* +One `session_reset()` called from the map-change path (`hl2h.cpp:784-793` +already has the hook point) that clears every file-scope cache: +`bones_clear`, `srv_checked`, `owned_mask`, `proj_first_seen`, bone ring +buffer, `g_scr_*`, spread state, aim/silent locks, item/proj arrays, +`g_map_last/prev`. Add a debug counter so a forgotten cache is visible. + +### P3-5 Header split and dependency direction *(M)* +Split `hl2h.h` into `common.h` / `core.h` / `settings.h` / `interfaces.h` / +`netvars.h` / `server.h` / `entity_query.h` / `bones.h` / `trace.h` / +`overlay.h` / `ui.h`. Enforce: features never include `hooks.h`/`init.h`; +`overlay.h` is the only place that knows ISurface slots; `common.h` depends on +nothing. This kills the star include graph and the circular +`hl2h.cpp ↔ menu.cpp` / `esp.cpp ↔ menu.cpp` / `studio.cpp ↔ esp.cpp` edges. + +### P3-6 Hook registry and teardown *(S)* +A small table of `{name, target, slot, original, replacement, installed}` with +`install_all()` / `restore_all()`, used by `hooks.cpp`. Enables the readiness +gate (P0-1) to be uniform and gives you an unload path for testing. + +### P3-7 Build and launch hygiene *(S)* +- `build.sh`: add `-pthread` (code uses `pthread_create`, + `std::mutex`, and links `-Wl,--no-undefined`); add object files + + dependency tracking for incremental builds; add an optional `-g`/`NDEBUG` + switch so crash addresses are symbolisable; fail loudly with a clear message + if `-m32` is unavailable; verify `assets/` is next to the built `.so`. +- `play.sh`: don't export `LD_PRELOAD` to the 64-bit Steam runtime wrapper + (every helper process warns about `ELFCLASS32`); set it only for the 32-bit + `hl2.sh` invocation, or re-exec the 32-bit binary directly. Move the + hardcoded `+map d2_coast_03` into a variable. +- Add `crash` symbolisation instructions (`addr2line -e libhl2hake.so`). + +--- + +## 5. Testing strategy + +The codebase has no tests, which is why most of these bugs are found by +inspection. Most of the P1/P2 defects live in pure functions; extract and test +them natively. + +1. **`tests/` with a 30-line assert harness**, compiled with the *host* + compiler (no `-m32`), no engine deps: + - config parser + `SettingDesc` clamping (P0-6, P3-2) + - `vector_angles` / `angle_to` / FOV math (P1-8) + - clip-rect and `text_in` truncation (P2-3) + - bone-cache eviction/validity policy as a pure `BoneCache` class + (P1-3) + - `proj_first_seen` ring (P1-9) + - seed selection / spread scoring (P1-6) + - `project_manual` aspect/roll (P2-8) + Compile with `-fsanitize=address,undefined`; run in CI-ish `make test`. +2. **Hook-level assertions** (debug builds only): stage must be 3 before a + feature runs; `find_modules` only from the setup thread; `seq >= 0`; + `g_srv_*` only read after a successful resolve. +3. **In-game smoke matrix** — run after every phase: + | Scenario | Covers | + |---|---| + | `+map` direct load, 10× | P0-1, P0-2 | + | main menu → load map → close GUI | P2-5 | + | death / respawn 20× | P2-7, P1-2 | + | map change A→B→A (HDR and LDR) | P1-2, P2-9, P3-4 | + | >64 NPCs on screen | P1-3 | + | resolution change + windowed resize | P2-8 | + | 60-min session | font/cache/leak growth | + | shotgun/pistol/crossbow strings | P1-5, P1-6 | +4. **Regression corpus:** keep `crash`, plus `addr2line` output per fixed + item, so a reintroduced crash is recognisable. + +--- + +## 6. Verified NOT a bug — do not fix + +These appeared in the audit but did not survive verification. Listed so they +don't get "fixed" into new bugs. + +| Claim | Reality | +|---|---| +| NPC `m_vecVelocity` read is OOB | Offset is `C_BaseEntity::m_vecVelocity` (`0xE0`), valid on all entities. Real issue is that NPCs don't network it → P1-10. | +| `CUtlVector` layout is wrong (`+12/+16`) | Correct for 32-bit (`utlvector.h:224-230`). Only range validation missing → P0-4. | +| Sky `memcpy` can overflow `dst` | Texture created at PNG dims, RGBA8888, so `pitch >= w*4 >= copy`. Add an assert, no fix. | +| `config_load` null deref is P0 | Real but only reachable from a hand-edited config (`config_save` never writes those keys). → P0-6, low. | +| `fog_enableskybox` stays on | `world.cpp:345-346` handles the `else` branch; the logic is correct. | +| Shotgun pellets need 48 `RandomFloat` draws | `RandomSeed` is called per pellet (`baseentity_shared.cpp:1699`), so 8 draws per pellet is enough. | +| Held `IN_ATTACK` breaks semi-autos | Source treats held `IN_ATTACK` as continuous fire; the concrete defect is the missing release edge for the pistol refire reset → folded into P1-5.5. | +| `bones_update` serves stale bones today | The missing `valid=false` is real but every in-tree caller checks the return. Fix anyway (P1-3) because it is one line. | +| `owned_mask` ORs forever | It resets when the local entity pointer changes; it is a same-map drop bug only. | +| `info_model_name` reads OOB | `name[64]` is followed by other header fields, so the 128-byte scan stays inside `studiohdr_t`. Correctness nit only (P0-8). | +| `draw_fov_circle` has no GUI toggle | Toggle exists (`gui.cpp:375`). Real bug is the `FEAT_ESP` gate → P1-11. | +| `%ebp` is safe because `-fno-omit-frame-pointer` | That flag only applies to this `.so`; callers are Valve binaries → P0-3. | + +--- + +## 7. Sequencing summary + +| Phase | Items | Effort | Gate to next phase | +|---|---|---|---| +| P0 | 8 | ~1.5 days | 10× direct-load and menu-load runs with no crash; netvars stable across map change | +| P1 | 11 | ~2.5 days | smoke matrix passes; aimbot/vischeck manual acceptance | +| P2 | 9 | ~2 days | visual acceptance (alpha, clipping, chams, skybox) + resize test | +| P3 | 7 | ~3 days | `tests/` green, `make` incremental, no behaviour diff on smoke matrix | + +Quick wins if you only have an afternoon: **P0-1, P0-5, P0-6, P0-7, P0-8**, +plus **P2-2** (slider 10 px) and **P1-6** (spread gate). Those are all +small, local, and remove a crash class or a user-visible bug each. + +--- + +## 8. Implementation log + +### Batch 1 — done, builds clean + +| Item | Status | Notes | +|---|---|---| +| P0-1 hook readiness gate | done | `init_stage()`/`init_live()` with acquire/release; all hooks pass through until `INIT_LIVE`; setup order is now config+surface → input → client hooks. `INIT_FAILED` on every fatal setup path. | +| P0-2 module snapshots | done | `Module` globals replaced by `mod_client()`/`mod_engine()`/`mod_server()` returning an immutable, atomically published snapshot. Retired snapshots are intentionally leaked so a reader's reference never dangles. No lock on the read path. | +| P0-3 `%ebp` walk | hardened | New `caller_self_arg()` validates that the walk stays inside the current stack (above `%esp`, <1 MiB, 4-aligned) before dereferencing. Still frame-pointer based; a future pass should drop it in favour of arming from `CreateMove` (option 1 in P0-3). Spread window now reset at every `do_move`. | +| P0-4 netvar bounds | done | `module_range(p, n)` added; every `RecvTable`/`RecvProp`/string/array dereference is validated before use. | +| P0-5 vtable header | done | `vtable_clone()` preserves `[-2]/[-1]`; used by client, CInput, IVModelRender and IStudioRender hooks. | +| P0-6 config `cur` | done | Stray `active=`/`bind=` in scalar/chams sections is ignored instead of dereferenced. | +| P0-7 signed `seq` | done | `seq < 0` rejected; modulo uses `unsigned`. | +| P0-8 defensive batch | partly | Bone parent-cycle guard, `mprotect` return check and no `PROT_EXEC`, `server.so` `.bss` tail absorbed, studio name bounded to 64. Not done: `build_core_mask` caching. | +| P1-6 spread gate | done | Seed hook now arms on `no_spread` only; `engine` toggle controls only the `RandomFloat` zeroing. | +| P2-2 slider geometry | done | `slider_track()` is the single source of truth for draw, hit-test and drag; drag clamps `t` to `[0,1]`. | + +Verification for this batch is compile-level (`build.sh`, `--no-undefined`) +plus a diff review. In-game runs still needed for P0-1/P0-2/P0-3 as described +in their own sections. + +### Batch 2 — done, builds clean + +| Item | Status | Notes | +|---|---|---| +| P1-1 dormant entities | done | `entity_is_dormant()` on `IClientNetworkable` slot 8, applied in esp_collect, aimbot candidate scan, the NPC visibility helper, melee, item ESP and projectile ESP. Slot layout is self-tested against the local player (which is never dormant): a wrong slot disables the check with a log line instead of misreporting every entity. | +| P1-2 `srv_checked` latch | done | Moved to file scope, exposed via `esp_reset_session()` called on local-entity change; only latches once both client and server model indices are valid. | +| P1-3 bone cache (part) | partly | `e->valid = false` now set on the two early returns that skipped it. Still to do: cache sizing/eviction and the `head_position` early-return. | +| P1-4 vischeck | done | Glass stepping advances until a probe is clear (doubling, capped, never past the target) instead of a fixed 0.5 units; `VisibilityFilter` ignores entities owned by the target via `m_hOwnerEntity`. | +| P1-5 aimbot | part | `maxs < 0` no longer disables the aimbot; prefilter widens the FOV test by the AABB angular radius; lock uses 1.5× hysteresis instead of no limit; the projectile lead point is re-checked for FOV and visibility and falls back to the un-led point. Still to do: `IN_ATTACK` release edge. | +| P1-7 owner netvars | done | `m_hOwnerEntity` / `m_hOwner` now resolved (previously always −1). | +| P1-8 math/movement | part | `vector_angles` pitch no longer wraps to `[270,360)`; `point_ground` requires `normal.z >= 0.7`; underwater jump+duck no longer skips combat (`goto bhop_done`); melee AABB rejects hugely negative `mins`. Still to do: triggerbot `entindex()`. | +| P1-9 fuse table | done | 64-entry ring with stale-entry reset; no longer freezes fuse time after 32 projectiles. | +| P1-11 FOV circle | done | Moved out of `esp_draw` into `outer_draw`, gated on aimbot/silent-aim being active plus the toggle, with its own screen-size init. | +| P2-1 draw once per frame | part | `outer_draw()` now has a 1 ms gate (panels within a frame are microseconds apart) and `DrawPrintText` only acts as a fallback when no paint happened for 50 ms. Still to do: save/restore ISurface text state. | + +### Batch 3 — done, builds clean + +| Item | Status | Notes | +|---|---|---| +| P2-3 clipping | done | `line()` is now Liang–Barsky clipped and used by checkbox checkmarks and picker cursors; `outline()` draws four clipped edges instead of outlining the clamped rect; `text_in()` truncates with `...` at `max_w`/clip edge and no longer shifts left. `s_outline` removed. | +| P2-4 pointer discipline | done | Clicking outside an open dropdown consumes the click (no activation of the control underneath); `handle_hover` no longer arms hidden sliders while a dropdown, picker, or drag is active. | +| P2-5 SDL state | part | `sd_*` pointers now resolve with `RTLD_NEXT` instead of `RTLD_DEFAULT` (which found this file's own wrapper); the wrappers record what the game last requested and `close_gui()` restores that instead of the pre-open snapshot. Not changed: dropping `SDL_MOUSEMOTION` while open. | +| P2-6 chams | part | `chams_reset()` now resets `AlphaModulate` (shared materials were permanently left half-faded); `chams_preload()` references each unique material once; prop tint restores white after `DrawModel`/`DrawModelStaticProp`. Not addressed: clearing `ForcedMaterialOverride` during shadow-depth passes (needs a way to read the engine's current override). | +| P2-7 fonts | part | `create_gui_font()` creates one handle and adds glyph sets to it instead of leaking one per failed family; `ensure_fonts()` keeps valid handles across level changes and only recreates when `font_valid()` fails. `menu.cpp`'s own font pair still resets on local-entity change. | +| P2-8 projection | done | `project_manual()` applies Source's 4:3-reference aspect scaling and includes roll; screen size is refreshed every frame through `esp_update_screen()` in all four draw paths. | +| P2-9 skybox | part | Pristine `g_sky_pixels_src` so HDR linearisation no longer bleeds into a later LDR map; `skybox_reload_image()` detaches the regenerator before dropping the handle. Original `ITexture*` refcounts still not taken (needs the ITexture slot verified against the shipped binary). | +| P3-4 session reset | done | One `session_reset()` called on local-entity change, listing every per-map cache. | +| P3-7 build | done | `-pthread`; incremental objects in `build/{release,debug}` (verified: second run compiles nothing); `DEBUG=1` for `-O0 -g`; explicit 32-bit toolchain check; assets warning; `build/` ignored. | + +Self-review during this batch caught one bug in my own P1-1 change: the +dormant self-test could run before a local player existed (main menu) and latch +"disabled" permanently. It now leaves the state untested until a local player +is available. + +### In-game validation + +Batch 1–3 items were exercised by the user in a live game and all behaved +correctly. Two follow-ups were found and fixed: + +- speed-graph background flicker — the 1 ms draw gate was not a frame boundary + (see the P2-1 follow-up below); +- FOV circle never visible — it was gated on `feature_active()`, which requires + the bind (default mouse5) to be held; now uses `feature_enabled()`. + +Both confirmed fixed. + +### Batch 4 — done, builds clean + +| Item | Status | Notes | +|---|---|---| +| P3-1 overlay layer | done | New `overlay.h` / `overlay.cpp` own the VGUI_Surface030 slot table and every ISurface call. `g_surface` moved there; `menu.cpp`, `gui.cpp` and `esp.cpp` keep their local wrapper names as one-line forwarders, so no call sites changed. Three duplicated slot tables are gone. | +| P1-3 bone cache | done | Cache raised to 192 entries (128 targets + room for failed updates); `head_position()` continues past a name-matched bone whose matrix is invalid instead of aborting the search. | +| P1-5 fire control | done | `fire_edge_ok()` alternates press/release for semi-auto weapons (pistol, .357, crossbow, shotgun, RPG, gravity gun, crowbar) and holds the button for automatic ones. Applied to silent aim, melee/gravity and triggerbot. | +| P1-10 NPC lead | done | New `server_entity_velocity()` reads the listen-server `m_vecVelocity` (already resolved but unused); `projectile_lead_point()` prefers it and falls back to the client field. | +| P2-9 `ITexture` refcounts | done | `record_orig()` now takes a reference (slot 10) on every captured texture and `release_sky_originals()` drops them (slot 11) wherever the list is cleared, so a map change can no longer leave `g_sky_orig` holding freed pointers. Slot numbers follow the material-system header order already verified here (SetTextureRegenerator = 12, Download = 13). | +| P2-5 motion events | done | `SDL_PollEvent` now keeps `SDL_MOUSEMOTION` events and zeroes only `xrel`/`yrel`, instead of dropping the event entirely (VGUI hover and SDL cursor state stay consistent). | +| P1-11 follow-up (circle) | done | The circle was gated on `feature_active()`, which requires the bind to be held; the default aimbot bind is mouse5, so it only appeared while aiming. `feature_enabled()` (toggle only) is now used, and the circle is skipped when there is no local player (main menu). | +| P2-1 follow-up (flicker) | done | The 1 ms time gate was the wrong frame boundary: panels more than 1 ms apart in one frame drew twice, stacking the 120-alpha speed-graph background, so it alternated brightness. `overlay_frame_mark()` is now called from `FRAME_RENDER_START` and `overlay_should_draw()` allows exactly one draw per engine frame, with a 10 ms fallback for menu/pause frames that never send the stage. | + +### Performance pass + +The audit's hot paths were removed rather than micro-optimised: + +| Cost | Before | After | +|---|---|---| +| NPC classification from the chams hook | `esp_npc_relation()` did `esp_is_npc_index` → entity/model lookup → `server_npc_relation` (edict + `CUtlVector` scan) + `server_npc_state` per model draw, up to 3 passes per entity per frame | 50 ms TTL cache keyed by entity index; a draw now costs a hash lookup | +| Friendly/neutral test | `server_npc_relation` + `server_npc_state` (+ turret flags) per call, from the entity scan, aimbot selection and chams | 100 ms TTL cache | +| Damage class | `server_npc_takedamage` (edict lookup) + ~20 `strcasestr` per NPC per scan | 250 ms TTL cache | +| Model name | two `IVModelInfo` virtual calls per entity per scan (~1000 entities at 100 Hz) | memoised by model index, cleared on map change | +| Triggerbot index recovery | walked up to 4096 entity-list slots per fire tick | direct-mapped entity-pointer → index cache filled by the entity scan; the scan remains only as a cold-miss fallback | + +Item and projectile markers were the other half of the problem: discovery +(`items_collect` / `projs_collect`) is throttled to 10 Hz because it walks the +entity list, but both draw paths already stored the entity index. They now +refresh the origin from the live netvar per frame (`entity_origin()`), so +markers, blast-radius rings and the grenade trajectory follow the world at +render rate while the expensive scan stays at 10 Hz. The trajectory also uses +the networked `m_DmgRadius` instead of a hardcoded 300, and its landing ring +respects the "Blast radius" toggle. + +Caches are cleared by `esp_reset_session()` on map change/respawn. The ESP scan +itself is unchanged: projection only happens for the handful of entities that +survive the class/life/friendly filters, not for every entity. + +### Known remaining work + +Deliberately deferred, with reasons: + +- **P1-8 triggerbot `entindex()`** — the current code is correct, only O(N) + per fire tick. Replacing it needs a verified `IClientNetworkable::entindex` + slot plus a way to get the networkable from a raw entity pointer, or a + cached pointer→index map. Not worth the risk for a hot path that fires a few + times per second. +- **P2-6 shadow-depth `ForcedMaterialOverride`** — requires reading the + engine's current override, which VEngineModel016 does not expose. Needs an + alternative detection (draw flags in `DrawModelState_t`) validated in-game. +- **P3-2 declarative settings table** — large rewrite of `config_save`, + `config_load`, `config_reset_defaults` and `build_cards`. It can regress + config persistence, so it should follow an in-game pass. +- **P3-3 `esp.cpp` split**, **P3-5 header split**, **P3-6 hook registry** — + behaviour-neutral moves with a large diff; best done after the current build + has been validated in-game so a regression is easy to bisect. + +Nothing in the two batches has been exercised in-game yet. The smoke matrix in +§5 is the next step: direct map load 10× (P0-1), menu → map → GUI close (P2-5), +map change A→B→A (P1-2/P2-9), >64 NPCs (P1-3), and a 60-minute session. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..12a6f92 --- /dev/null +++ b/Makefile @@ -0,0 +1,60 @@ +# libhl2hake.so — 32-bit Source Engine plugin. +# +# make release build (build/release, -O2) +# make debug build/debug with -O0 -g (symbolise crash addresses) +# make clean +# +# Needs a 32-bit multilib toolchain (g++-multilib on Debian/Ubuntu). + +CXX ?= g++ +OUT := libhl2hake.so +SRC := $(wildcard src/*.cpp) +ODIR ?= build/release +OPT ?= -O2 + +CXXFLAGS := -m32 -std=c++17 -Wall -Wextra -Werror -fPIC \ + -fvisibility=hidden -fno-omit-frame-pointer -pthread \ + -MMD -MP $(OPT) +LDFLAGS := -m32 -shared -pthread -Wl,--no-undefined +LDLIBS := -ldl + +OBJ := $(patsubst src/%.cpp,$(ODIR)/%.o,$(SRC)) +DEP := $(OBJ:.o=.d) + +all: check $(OUT) + @file $(OUT) + @test -f assets/LiberationSans-Regular.ttf || \ + echo "warning: assets/ is missing next to the .so; the overlay falls back to system fonts" >&2 + +# Objects, dependency files and headers are handled by make itself; this only +# keeps the debug build in its own directory. +debug: + @$(MAKE) --no-print-directory ODIR=build/debug OPT="-O0 -g" + +# Always relink. Both configs write the same output file, so without this a +# plain `make` after `make debug` would keep the debug binary (the .so is newer +# than the release objects). Linking is sub-second. +.PHONY: FORCE +FORCE: + +$(OUT): $(OBJ) FORCE + $(CXX) $(LDFLAGS) -o $@ $(OBJ) $(LDLIBS) + +$(ODIR)/%.o: src/%.cpp | $(ODIR) + $(CXX) $(CXXFLAGS) -c $< -o $@ + +$(ODIR): + @mkdir -p $@ + +# Fail once with a usable message instead of a wall of compiler errors. +.PHONY: check +check: + @printf 'int main(){return 0;}\n' | \ + $(CXX) -m32 -x c++ - -o /dev/null 2>/dev/null || \ + { echo "error: $(CXX) cannot build 32-bit binaries; install g++-multilib" >&2; exit 1; } + +.PHONY: all debug clean +clean: + rm -rf build $(OUT) + +-include $(DEP) diff --git a/build.sh b/build.sh index b12f67b..4d10a56 100755 --- a/build.sh +++ b/build.sh @@ -1,8 +1,10 @@ #!/bin/sh -set -e -cd "$(dirname "$0")" -g++ -m32 -O2 -std=c++17 -Wall -Wextra -Werror -fPIC -shared -fvisibility=hidden -fno-omit-frame-pointer \ - -o libhl2hake.so \ - src/hl2h.cpp src/modules.cpp src/cinput.cpp src/netvars.cpp src/menu.cpp src/gui.cpp src/esp.cpp src/server.cpp src/bones.cpp src/vischeck.cpp src/world.cpp src/studio.cpp \ - -ldl -Wl,--no-undefined -file libhl2hake.so +# The build is defined in Makefile. This wrapper exists only so the old +# ./build.sh invocation (and the docs that mention it) keep working. +# +# ./build.sh -> make +# DEBUG=1 ./build.sh -> make debug +if [ "${DEBUG:-0}" = "1" ]; then + exec make --no-print-directory debug "$@" +fi +exec make --no-print-directory "$@" diff --git a/src/bones.cpp b/src/bones.cpp index c777067..c4168a9 100644 --- a/src/bones.cpp +++ b/src/bones.cpp @@ -100,7 +100,10 @@ struct BoneEntry { Matrix3x4 mats[MAX_BONES]; }; -static BoneEntry g_entries[64]; +// Sized for the 128-target scan with headroom: failed updates also allocate an +// entry, and 64 slots evicted live NPCs mid-frame. +constexpr int BONE_CACHE = 192; +static BoneEntry g_entries[BONE_CACHE]; static int g_next = 0; static int g_logged = 0; static bool g_renderable_warned = false; @@ -112,14 +115,14 @@ static double bone_now() { } static BoneEntry *find_entry(int idx) { - for (int i = 0; i < 64; i++) + for (int i = 0; i < BONE_CACHE; i++) if (g_entries[i].ent && g_entries[i].idx == idx) return &g_entries[i]; return nullptr; } static BoneEntry *alloc_entry(int idx, void *ent) { - BoneEntry *e = &g_entries[g_next++ % 64]; + BoneEntry *e = &g_entries[g_next++ % BONE_CACHE]; memset(e, 0, sizeof(*e)); e->idx = idx; e->ent = ent; @@ -321,13 +324,17 @@ bool bones_update(int idx, void *ent) { const void *expected = vfunc(g_iface.modelinfo, 2)(g_iface.modelinfo, modelindex); - if (!expected) + if (!expected) { + e->valid = false; return false; + } // IClientUnknown::GetClientRenderable = primary vtable slot 6. void *r = vfunc(ent, 6)(ent); - if (!r) + if (!r) { + e->valid = false; return false; + } // Validate the interface before using SetupBones on it. const void *rmodel = vfunc(r, 9)(r); if (rmodel != expected) { @@ -418,9 +425,14 @@ int bone_nearest_core_parent(int idx, int bone) { return -1; int p = bone_at(e->hdr, bone)->parent; if (!e->humanoid) { - while (p >= 0 && p < e->count && !e->bone_valid[p]) + // Same cycle guard as the core path: a malformed model can self-parent a + // bone whose matrix is invalid, which would spin here forever. + for (int guard = 0; p >= 0 && p < e->count && guard < 64; guard++) { + if (e->bone_valid[p]) + return p; p = bone_at(e->hdr, p)->parent; - return (p >= 0 && p < e->count) ? p : -1; + } + return -1; } for (int guard = 0; p >= 0 && p < e->count && guard < 64; guard++) { if (e->core[p] && e->bone_valid[p]) @@ -487,14 +499,16 @@ bool head_position(int idx, Vector &out) { if (boxes[i].bone < 0 || boxes[i].bone >= e->count) continue; const char *bn = bone_name_raw(e->hdr, boxes[i].bone); - if (bn && head_like_name(bn)) - return hitbox_world(e, boxes[i], out); + // Keep looking: the first name match can be a bone whose matrix failed + // validation, and returning its false result aborted the whole search. + if (bn && head_like_name(bn) && hitbox_world(e, boxes[i], out)) + return true; } } for (int i = 0; i < e->count; i++) { const char *bn = bone_name_raw(e->hdr, i); - if (bn && head_like_name(bn)) - return bone_pos(idx, i, out); + if (bn && head_like_name(bn) && bone_pos(idx, i, out)) + return true; } // Last resort for non-humanoid models with unnamed heads/hitboxes: the // first hitbox is the body (still a valid aim point). diff --git a/src/cinput.cpp b/src/cinput.cpp index 08686a1..45e2b04 100644 --- a/src/cinput.cpp +++ b/src/cinput.cpp @@ -53,23 +53,25 @@ static constexpr size_t VERIFIED_STRIDE = 0x58; static constexpr size_t VERIFIED_CRC_OFF = 0x54; CUserCmd *get_usercmd(int seq) { - if (!g_input) + // Negative sequence numbers would make `seq % 90` negative and the size_t + // cast a huge index; reject before forming the pointer. + if (!g_input || seq < 0) return nullptr; uintptr_t cmds = *(uintptr_t *)(g_input + CMDOFF_M_PCOMMANDS); if (!cmds) return nullptr; - auto *cmd = (CUserCmd *)(cmds + (size_t)(seq % 90) * 0x54); + auto *cmd = (CUserCmd *)(cmds + (size_t)((unsigned)seq % 90u) * 0x54); if (cmd->command_number != seq) return nullptr; return cmd; } void fix_verified_crc(int seq, uint32_t crc) { - if (!g_input) + if (!g_input || seq < 0) return; uintptr_t verified = *(uintptr_t *)(g_input + CMDOFF_M_PVERIFIED); if (verified) - *(uint32_t *)(verified + (size_t)(seq % 90) * VERIFIED_STRIDE + + *(uint32_t *)(verified + (size_t)((unsigned)seq % 90u) * VERIFIED_STRIDE + VERIFIED_CRC_OFF) = crc; } @@ -92,6 +94,11 @@ static InputExtraMouseFn oInputExtraMouseSample = nullptr; // commands. While the GUI is open it must not touch the view; movement still // comes through CreateMove. static void hkdInputExtraMouseSample(void *self, float frametime, bool active) { + if (!init_live()) { + if (oInputExtraMouseSample) + oInputExtraMouseSample(self, frametime, active); + return; + } if (gui_is_open()) return; if (oInputExtraMouseSample) @@ -109,6 +116,11 @@ void input_clear_mouse_states() { static void hkdInputCreateMove(void *self, int seq, float frametime, bool active) { + if (!init_live()) { + if (oInputCreateMove) + oInputCreateMove(self, seq, frametime, active); + return; + } // Mouse deltas are accumulated between ticks. With the custom GUI open we // must discard them before CInput::CreateMove applies mouse look, otherwise // the view moves for one frame and snaps back when do_move freezes it. @@ -151,8 +163,11 @@ static bool install_input_hook() { if (!vt) return false; constexpr size_t N = 64; - void **nvt = new void *[N]; - memcpy(nvt, vt, N * sizeof(void *)); + void **nvt = vtable_clone((void *)g_input, N); + if (!nvt) { + logf("CInput vtable clone failed"); + return false; + } oInputCreateMove = (InputCreateMoveFn)vt[3]; nvt[3] = (void *)&hkdInputCreateMove; if (N > 4) { @@ -173,7 +188,7 @@ static bool install_input_hook() { bool find_input_global() { void **vt = VTABLE(g_iface.client); uintptr_t cm = (uintptr_t)vt[21]; // CreateMove slot - if (!g_client.contains(cm)) { + if (!mod_client().contains(cm)) { logf("CreateMove %p outside client module", (void *)cm); return false; } @@ -189,7 +204,7 @@ bool find_input_global() { break; } } - if (!anchor || !g_client.contains(anchor)) { + if (!anchor || !mod_client().contains(anchor)) { logf("PIC anchor not found in CreateMove"); return false; } @@ -214,16 +229,16 @@ bool find_input_global() { uint32_t disp; memcpy(&disp, b + i + 2, 4); uintptr_t var = anchor + disp; - if (!g_client.contains(var)) + if (!mod_client().contains(var)) continue; uintptr_t input = *(uintptr_t *)var; - if (!input || !g_client.contains(input)) + if (!input || !mod_client().contains(input)) continue; uintptr_t vtbl = *(uintptr_t *)input; - if (!vtbl || !g_client.contains(vtbl)) + if (!vtbl || !mod_client().contains(vtbl)) continue; uintptr_t f3 = *(uintptr_t *)(vtbl + 3 * sizeof(void *)); - if (!g_client.contains(f3)) + if (!mod_client().contains(f3)) continue; logd("CInput* = %#lx (ptr var %#lx, vfunc[3] %#lx)", (unsigned long)input, (unsigned long)var, (unsigned long)f3); diff --git a/src/esp.cpp b/src/esp.cpp index 72110cc..81ec81c 100644 --- a/src/esp.cpp +++ b/src/esp.cpp @@ -7,12 +7,6 @@ #include #include -// ISurface slots (binary = SDK header + 4, disassembly-confirmed) -enum SurfSlot { - SURF_DrawSetColor = 10, SURF_FilledRect = 12, SURF_OutlinedRect = 14, - SURF_DrawLine = 15, SURF_GetScreenSize = 38, -}; - enum { LIFE_ALIVE = 0 }; struct EspTarget { @@ -40,6 +34,36 @@ static int g_ntargets = 0; static int g_scr_w = 0, g_scr_h = 0; static bool g_esp_logged = false; +// Listen-server sanity latch. Only ever set once per map (esp_reset_session), +// and only after both model indices are valid: latching on the transient +// cli_model == 0 at map load disabled server health for the whole process. +static bool g_srv_checked = false; +static bool g_srv_trusted = false; + +// Per-map caches. The ESP resolves a model name for every entity on every scan +// and the chams hook asks for a relation on every model draw; both are virtual +// call chains into the engine plus server datamap reads, so memoise them. +static constexpr int MODEL_CACHE = 16384; +static const char *g_model_name_cache[MODEL_CACHE]; +static unsigned char g_model_state_cache[MODEL_CACHE]; // 0 unknown,1 ok,2 fail + +// Direct-mapped entity pointer -> index map, filled by esp_collect. The +// triggerbot traces a ray and gets a pointer back, and used to find its index +// by walking up to 4096 entity-list slots on every fire tick. +static constexpr int ENTPTR_CACHE = 4096; +static void *g_entptr_key[ENTPTR_CACHE]; +static int g_entptr_idx[ENTPTR_CACHE]; + +void esp_reset_session() { + g_srv_checked = false; + g_srv_trusted = false; + g_esp_logged = false; + memset(g_model_name_cache, 0, sizeof(g_model_name_cache)); + memset(g_model_state_cache, 0, sizeof(g_model_state_cache)); + memset(g_entptr_key, 0, sizeof(g_entptr_key)); + memset(g_entptr_idx, 0, sizeof(g_entptr_idx)); +} + // Speed graph: 100 samples at 0.1 s = the last 10 seconds. static constexpr int SPEED_SAMPLES = 100; static float g_speed_samples[SPEED_SAMPLES] = {}; @@ -52,6 +76,21 @@ static double esp_now() { return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9; } +// Refresh the cached screen size every frame. It used to be read once, so a +// resolution or window change left every projection, screen-edge clamp and +// centering calculation stale for the rest of the session. +static bool esp_update_screen() { + if (!g_surface) + return false; + int w = 0, h = 0; + overlay_screen_size(w, h); + if (w > 0) + g_scr_w = w; + if (h > 0) + g_scr_h = h; + return g_scr_w > 0 && g_scr_h > 0; +} + // Local perspective projection used when the engine's debug-overlay // projection refuses a point that is actually in front of the camera. static bool project_manual(const Vector &w, float &sx, float &sy) { @@ -59,11 +98,16 @@ static bool project_manual(const Vector &w, float &sx, float &sy) { return false; float pitch = g_cam_view.x * PI_F / 180.0f; float yaw = g_cam_view.y * PI_F / 180.0f; + float roll = g_cam_view.z * PI_F / 180.0f; float sp = sinf(pitch), cp = cosf(pitch); float syy = sinf(yaw), cy = cosf(yaw); + float sr = sinf(roll), cr = cosf(roll); float fwd[3] = {cp * cy, cp * syy, -sp}; - float right[3] = {syy, -cy, 0}; - float up[3] = {sp * cy, sp * syy, cp}; + // Source's AngleVectors with roll; the old right/up vectors assumed roll 0, + // so view punch made the fallback projection rotate against the world. + float right[3] = {-sr * sp * cy + cr * syy, -sr * sp * syy - cr * cy, + -sr * cp}; + float up[3] = {cr * sp * cy + sr * syy, cr * sp * syy - sr * cy, cr * cp}; float dx = w.x - g_cam_eye.x, dy = w.y - g_cam_eye.y, dz = w.z - g_cam_eye.z; float z = dx * fwd[0] + dy * fwd[1] + dz * fwd[2]; if (z < 1.0f) @@ -71,7 +115,10 @@ static bool project_manual(const Vector &w, float &sx, float &sy) { float x = dx * right[0] + dy * right[1] + dz * right[2]; float y = dx * up[0] + dy * up[1] + dz * up[2]; float fov = g_fov_camera > 10 ? (float)g_fov_camera : 75.0f; - float tanfov = tanf(fov * 0.5f * PI_F / 180.0f); + // Source defines the FOV for a 4:3 reference and widens it on widescreen; + // using a raw tan(fov/2) made the fallback ~33% too narrow at 16:9. + float aspect = (float)g_scr_w / (float)g_scr_h; + float tanfov = tanf(fov * 0.5f * PI_F / 180.0f) * (aspect / (4.0f / 3.0f)); sx = (float)g_scr_w * 0.5f + (x / z) * ((float)g_scr_w * 0.5f / tanfov); sy = (float)g_scr_h * 0.5f - (y / z) * ((float)g_scr_w * 0.5f / tanfov); return std::isfinite(sx) && std::isfinite(sy) && fabsf(sx) < 1e7f && @@ -137,16 +184,96 @@ static const char *entity_class_name(void *net, void **out_cc) { return cc ? *(const char **)((uintptr_t)cc + 8) : nullptr; } +// IClientNetworkable slot 8 = IsDormant (SDK order: GetClientClass 2, IsDormant +// 8, entindex 9). The shipped binary is not guaranteed to match the 2013 SDK, +// so the slot is validated once against the local player, which can never be +// dormant: a wrong slot disables the check instead of misreporting entities. +// Dormant entities keep their last networked origin, so drawing or aiming at +// them produces ghosts at stale positions. +static int g_dormant_state = 0; // 0 untested, 1 enabled, -1 disabled + +static void dormant_selftest() { + // Leave the state untested (0) until a local player exists: the first call + // can come from the main menu, and latching "disabled" there would turn the + // check off for the whole session. + if (!g_iface.entitylist) + return; + int local = engine_get_local_player(); + if (local <= 0) + return; + void *net = entity_networkable(local); + if (!net) + return; + void **vt = *(void ***)net; + if (!vt || !mod_client().contains((uintptr_t)vt[8])) { + g_dormant_state = -1; + logf("dormant check disabled: slot 8 outside client module"); + return; + } + using DormantFn = unsigned char (*)(void *); + if (vfunc(net, 8)(net) != 0) { + g_dormant_state = -1; + logf("dormant check disabled: local player reported dormant"); + return; + } + g_dormant_state = 1; + logd("dormant check enabled"); +} + +static bool entity_is_dormant(void *net) { + if (!net) + return false; + if (g_dormant_state == 0) + dormant_selftest(); + if (g_dormant_state != 1) + return false; + using DormantFn = unsigned char (*)(void *); + return vfunc(net, 8)(net) != 0; +} + +// Entity pointer -> index, filled by the entity scans. The triggerbot gets a +// pointer back from the trace and needs the index; the alternative is walking +// up to 4096 entity-list slots on every fire tick. +static int entptr_lookup(void *ent) { + if (!ent) + return -1; + unsigned h = (unsigned)(((uintptr_t)ent >> 4) & (ENTPTR_CACHE - 1)); + return g_entptr_key[h] == ent ? g_entptr_idx[h] : -1; +} + +static void entptr_store(void *ent, int idx) { + if (!ent || idx <= 0) + return; + unsigned h = (unsigned)(((uintptr_t)ent >> 4) & (ENTPTR_CACHE - 1)); + g_entptr_key[h] = ent; + g_entptr_idx[h] = idx; +} + // m_nModelIndex -> IVModelInfoClient(006)::GetModel/GetModelName. // Itanium ABI: two dtor slots, so GetModel=2, GetModelName=4. static const char *model_name_from_index(int modelindex) { if (!g_iface.modelinfo || modelindex <= 0) return nullptr; + if (modelindex < MODEL_CACHE) { + if (g_model_state_cache[modelindex] == 1) + return g_model_name_cache[modelindex]; + if (g_model_state_cache[modelindex] == 2) + return nullptr; + } const void *model = vfunc(g_iface.modelinfo, 2)(g_iface.modelinfo, modelindex); - if (!model) + if (!model) { + if (modelindex < MODEL_CACHE) + g_model_state_cache[modelindex] = 2; return nullptr; - return vfunc(g_iface.modelinfo, 4)(g_iface.modelinfo, model); + } + const char *name = + vfunc(g_iface.modelinfo, 4)(g_iface.modelinfo, model); + if (modelindex < MODEL_CACHE) { + g_model_name_cache[modelindex] = name; + g_model_state_cache[modelindex] = name ? 1 : 2; + } + return name; } static const char *entity_model_name(void *ent) { @@ -155,6 +282,19 @@ static const char *entity_model_name(void *ent) { return model_name_from_index(modelindex); } +// Live origin for a cached entity index. Item/projectile discovery is throttled +// to 10 Hz (it walks the whole entity list), but the markers must follow the +// world at render rate; refreshing one netvar read per cached target is cheap. +static bool entity_origin(void *ent, Vector &out) { + if (!ent || g_net.origin < 0) + return false; + uintptr_t e = (uintptr_t)ent; + out.x = *(float *)(e + g_net.origin); + out.y = *(float *)(e + g_net.origin + 4); + out.z = *(float *)(e + g_net.origin + 8); + return true; +} + // HL2 SP: generic NPCs (combine, metrocop, zombie, citizen, antlion, ...) all // use the single client class "CAI_BaseNPC" — only special NPCs register their // own class names (CNPC_Manhack, CNPC_Strider, CNPC_CombineGunship, ...). @@ -192,20 +332,35 @@ bool esp_is_viewmodel_index(int idx) { static bool npc_is_friendly(int idx, const char *model, int local_idx, bool &neutral); int esp_npc_relation(int idx) { - if (!esp_is_npc_index(idx)) - return -1; - int local = engine_get_local_player(); - if (local <= 0) - return 0; - void *ent = entitylist_get(idx); - const char *model = ent ? entity_model_name(ent) : nullptr; - bool neutral = false; - bool friendly = npc_is_friendly(idx, model, local, neutral); - if (neutral) - return 1; - if (friendly) - return 2; - return 0; + // Called from the chams hook up to three times per entity per frame (visible, + // through-wall, wireframe pass) and the work behind it is a chain of server + // edict/datamap reads. A short TTL cache keeps that out of the render path. + struct RelCache { + int idx; + double at; + int val; + }; + static RelCache cache[256]; + double now = esp_now(); + RelCache &c = cache[(unsigned)idx & 255]; + if (c.idx == idx && now - c.at < 0.05) + return c.val; + + int val = -1; + if (esp_is_npc_index(idx)) { + int local = engine_get_local_player(); + if (local <= 0) { + val = 0; + } else { + void *ent = entitylist_get(idx); + const char *model = ent ? entity_model_name(ent) : nullptr; + bool neutral = false; + bool friendly = npc_is_friendly(idx, model, local, neutral); + val = neutral ? 1 : friendly ? 2 : 0; + } + } + c = {idx, now, val}; + return val; } static bool model_is_friendly(const char *m) { @@ -237,8 +392,8 @@ static bool model_is_floor_turret(const char *m) { return m && strstr(m, "floor_turret"); } -static bool npc_is_friendly(int idx, const char *model, int local_idx, - bool &neutral) { +static bool npc_is_friendly_uncached(int idx, const char *model, int local_idx, + bool &neutral) { int rel = server_npc_relation(idx, local_idx); int state = server_npc_state(idx); int turret = -1; @@ -302,6 +457,28 @@ static bool npc_is_friendly(int idx, const char *model, int local_idx, return friendly; } +// Friendly/neutral state changes slowly (hacked turrets, bugbait), while the +// entity scan and the chams hook ask for it constantly. Cache it briefly so the +// server lookups happen once per NPC per window instead of per call. +static bool npc_is_friendly(int idx, const char *model, int local_idx, + bool &neutral) { + struct Cache { + int idx; + double at; + bool friendly; + bool neutral; + }; + static Cache cache[256]; + double now = esp_now(); + Cache &c = cache[(unsigned)idx & 255]; + if (c.idx == idx && now - c.at < 0.10) { + neutral = c.neutral; + return c.friendly; + } + bool f = npc_is_friendly_uncached(idx, model, local_idx, neutral); + c = {idx, now, f, neutral}; + return f; +} enum NpcDamageClass { NPC_DMG_NORMAL = 0, @@ -316,7 +493,7 @@ static bool model_has(const char *model, const char *needle) { // Server m_takedamage first (authoritative immunity), then the HL2 damage // rules for units whose OnTakeDamage filters specific damage types. -static int npc_damage_class(int idx, const char *model) { +static int npc_damage_class_uncached(int idx, const char *model) { int td = server_npc_takedamage(idx); if (td == 0 || td == 1) // DAMAGE_NO / DAMAGE_EVENTS_ONLY return NPC_DMG_IMMUNE; @@ -330,6 +507,24 @@ static int npc_damage_class(int idx, const char *model) { return NPC_DMG_NORMAL; } +// Damage class depends on m_takedamage (a server read) plus model string +// matching. It changes even more rarely than the relation, so cache it. +static int npc_damage_class(int idx, const char *model) { + struct Cache { + int idx; + double at; + int val; + }; + static Cache cache[256]; + double now = esp_now(); + Cache &c = cache[(unsigned)idx & 255]; + if (c.idx == idx && now - c.at < 0.25) + return c.val; + int v = npc_damage_class_uncached(idx, model); + c = {idx, now, v}; + return v; +} + static bool active_weapon_contains(void *local_ent, const char *needle); // Armoured units need blast damage; the RPG is the only stock weapon that @@ -430,15 +625,14 @@ static void esp_collect(int local_idx) { if (hi < 0 || hi > 4096) hi = 4096; int npc_alive = 0, dbg_lines = 0; bool srv = resolve_server_netvars(); - static bool srv_checked = false; - static bool srv_trusted = false; for (int idx = 1; idx <= hi && g_ntargets < 128; idx++) { if (idx == local_idx) continue; void *ent = entitylist_get(idx); if (!ent) continue; + entptr_store(ent, idx); void *net = entity_networkable(idx); - if (!net) continue; + if (!net || entity_is_dormant(net)) continue; const char *cls = entity_class_name(net, nullptr); if (!cls) continue; @@ -456,19 +650,24 @@ static void esp_collect(int local_idx) { int slife = -1, smax = -1; void *sbase = nullptr; int shp = server_entity_health(idx, &smax, &slife, &sbase); - if (!srv_checked && sbase) { - srv_checked = true; + if (!g_srv_checked && sbase) { int cli_mi = g_net.modelindex >= 0 ? *(int *)(e + g_net.modelindex) : -1; int srv_mi = server_model_index(sbase); - srv_trusted = (cli_mi == srv_mi); - logd("ESP server check: idx=%d base=%p cli_model=%d srv_model=%d " - "hp=%d maxhp=%d life=%d trusted=%d", - idx, sbase, cli_mi, srv_mi, shp, smax, slife, (int)srv_trusted); - if (!srv_trusted) - logf("ESP: server entity mismatch, ignoring server values"); + // Wait for both indices: the client index is often 0 for a tick right + // after a map load, which used to latch "untrusted" permanently. + if (cli_mi > 0 && srv_mi > 0) { + g_srv_checked = true; + g_srv_trusted = (cli_mi == srv_mi); + logd("ESP server check: idx=%d base=%p cli_model=%d srv_model=%d " + "hp=%d maxhp=%d life=%d trusted=%d", + idx, sbase, cli_mi, srv_mi, shp, smax, slife, + (int)g_srv_trusted); + if (!g_srv_trusted) + logf("ESP: server entity mismatch, ignoring server values"); + } } - if (srv_trusted) { + if (g_srv_trusted) { if (shp >= 0) hp = shp; if (smax > 0) maxhp = smax; if (slife >= 0) life = slife; @@ -576,16 +775,16 @@ static void esp_collect(int local_idx) { } static void surf_rect(int filled, int x0, int y0, int x1, int y1) { - using Fn = void (*)(void *, int, int, int, int); - vfunc(g_surface, filled ? SURF_FilledRect : SURF_OutlinedRect)(g_surface, x0, y0, x1, y1); + if (filled) + overlay_fill(x0, y0, x1, y1); + else + overlay_outline_rect(x0, y0, x1, y1); } static void surf_color(int r, int g, int b, int a) { - using Fn = void (*)(void *, int, int, int, int); - vfunc(g_surface, SURF_DrawSetColor)(g_surface, r, g, b, a); + overlay_color(r, g, b, a); } static void surf_line(int x0, int y0, int x1, int y1) { - using Fn = void (*)(void *, int, int, int, int); - vfunc(g_surface, SURF_DrawLine)(g_surface, x0, y0, x1, y1); + overlay_line(x0, y0, x1, y1); } // Screen projection of the aimbot FOV cone boundary, using the engine's own @@ -599,8 +798,14 @@ static QAngle engine_view_angles() { return v; } -static void draw_fov_circle() { - if (!aimbot_fov_circle()) +void esp_draw_fov_circle() { + if (!g_surface || !aimbot_fov_circle()) + return; + // No local player means no game world (main menu); the cached camera is stale + // and the projected circle would land at a meaningless spot. + if (engine_get_local_player() <= 0) + return; + if (!esp_update_screen()) return; float fov = (float)aimbot_fov(); if (fov < 1.0f || fov >= 89.0f) // beyond this the "circle" is behind the eye @@ -812,7 +1017,9 @@ static bool select_aim_target(const QAngle &view, unsigned bone_mask, out_idx = -1; out_point = {}; out_fov = 0.0f; - if (!g_iface.entitylist || g_net.origin < 0 || g_net.maxs < 0) + // maxs is optional: the per-target code falls back to 72 units when the + // netvar is missing, so it must not disable the whole aimbot here. + if (!g_iface.entitylist || g_net.origin < 0) return false; int local = engine_get_local_player(); if (local <= 0) @@ -830,7 +1037,8 @@ static bool select_aim_target(const QAngle &view, unsigned bone_mask, bool rpg = active_weapon_contains(local_ent, "rocket_launcher") || active_weapon_contains(local_ent, "rpg"); - auto try_target = [&](int idx, bool enforce_fov, Vector &pt, + // fov_limit <= 0 means "no angular limit" (kept for unlimited acquisition). + auto try_target = [&](int idx, float fov_limit, Vector &pt, float &fov) -> bool { if (idx <= 0 || idx == local) return false; @@ -838,7 +1046,7 @@ static bool select_aim_target(const QAngle &view, unsigned bone_mask, if (!ent) return false; void *net = entity_networkable(idx); - if (!net) + if (!net || entity_is_dormant(net)) return false; const char *cls = entity_class_name(net, nullptr); if (!cls || !is_npc_class(cls)) @@ -863,7 +1071,7 @@ static bool select_aim_target(const QAngle &view, unsigned bone_mask, Vector best_pt{}; for (int i = 0; i < npoints; i++) { float f = getfov(view, angle_to(g_cam_eye, pts[i])); - if (enforce_fov && f > max_fov) + if (fov_limit > 0.0f && f > fov_limit) continue; if (f >= best) continue; @@ -880,11 +1088,13 @@ static bool select_aim_target(const QAngle &view, unsigned bone_mask, }; // Keep the locked target while it is alive and has a visible selected point. - // No FOV limit on a locked target; only acquisition is FOV-limited. + // The lock uses a wider cone than acquisition (hysteresis), so a target that + // drifts slightly off the crosshair is not dropped, but one that swings + // behind the player is: an unbounded lock let silent aim fire backwards. if (lock_idx && *lock_idx >= 0) { Vector pt; float f; - if (try_target(*lock_idx, false, pt, f)) { + if (try_target(*lock_idx, max_fov * 1.5f, pt, f)) { out_idx = *lock_idx; out_point = pt; out_fov = f; @@ -920,7 +1130,7 @@ static bool select_aim_target(const QAngle &view, unsigned bone_mask, if (!ent) continue; void *net = entity_networkable(idx); - if (!net) + if (!net || entity_is_dormant(net)) continue; const char *cls = entity_class_name(net, nullptr); if (!cls || !is_npc_class(cls)) @@ -949,7 +1159,17 @@ static bool select_aim_target(const QAngle &view, unsigned bone_mask, Vector approx{origin.x, origin.y, origin.z + maxz * ((bone_mask & (1u << 1)) ? 0.9f : 0.55f)}; float fov = getfov(view, angle_to(g_cam_eye, approx)); - if (fov > max_fov) + // The prefilter samples a single point, but the selected hitbox can be a + // whole body-width away at close range. Reject only when the target's + // angular radius cannot reach the cone; try_target still enforces the + // exact per-hitbox FOV. + float ddx = approx.x - g_cam_eye.x; + float ddy = approx.y - g_cam_eye.y; + float ddz = approx.z - g_cam_eye.z; + float dist = sqrtf(ddx * ddx + ddy * ddy + ddz * ddz); + float margin = + dist > 1.0f ? atan2f(maxz, dist) * 180.0f / PI_F : 90.0f; + if (fov - margin > max_fov) continue; float key = (rpg && armored) ? fov - 1000.0f : fov; @@ -977,7 +1197,7 @@ static bool select_aim_target(const QAngle &view, unsigned bone_mask, for (int ci = 0; ci < ncands; ci++) { Vector pt; float f; - if (!try_target(cands[ci].idx, true, pt, f)) + if (!try_target(cands[ci].idx, max_fov, pt, f)) continue; out_idx = cands[ci].idx; out_point = pt; @@ -1043,9 +1263,28 @@ static bool projectile_lead_point(int target_idx, const Vector &point, return false; } void *target = entitylist_get(target_idx); - if (!target || g_net.velocity < 0) + if (!target) + return false; + // NPCs do not network C_BaseEntity::m_vecVelocity (only the player does), so + // the client field is stale for exactly the targets the aimbot uses. Read + // the listen-server entity when available and fall back to the client value. + float v[3] = {0.0f, 0.0f, 0.0f}; + Vector srv_vel; + bool have_vel = false; + if (server_entity_velocity(target_idx, &srv_vel)) { + v[0] = srv_vel.x; + v[1] = srv_vel.y; + v[2] = srv_vel.z; + have_vel = true; + } else if (g_net.velocity >= 0) { + const float *cv = (const float *)((uintptr_t)target + g_net.velocity); + v[0] = cv[0]; + v[1] = cv[1]; + v[2] = cv[2]; + have_vel = true; + } + if (!have_vel) return false; - const float *v = (const float *)((uintptr_t)target + g_net.velocity); float dx = point.x - g_cam_eye.x; float dy = point.y - g_cam_eye.y; float dz = point.z - g_cam_eye.z; @@ -1058,6 +1297,51 @@ static bool projectile_lead_point(int target_idx, const Vector &point, return true; } +// A led point is a prediction, not a verified aim point: acquisition checked +// the un-led position, so the shift has to pass the same FOV and visibility +// tests. On failure the caller keeps the original point instead of firing into +// geometry or outside the configured cone. +static bool lead_point_ok(int target_idx, const Vector &lead, + const QAngle &view, float max_fov) { + if (getfov(view, angle_to(g_cam_eye, lead)) > max_fov) + return false; + if (!aimbot_vischeck_enabled()) + return true; + void *local_ent = entitylist_get(engine_get_local_player()); + void *target_ent = entitylist_get(target_idx); + if (!local_ent || !target_ent) + return false; + return is_visible(g_cam_eye, lead, local_ent, target_ent); +} + +// Semi-auto weapons (pistol, .357, crossbow, shotgun, RPG, gravity gun) only +// fire on the IN_ATTACK press edge, so a synthetic button held every tick fires +// exactly once per buy/equip. Alternate press/release for those; automatic +// weapons keep the button held. +static bool weapon_is_semi_auto(void *local_ent) { + static const char *names[] = {"pistol", "357", + "crossbow", "shotgun", + "rocket_launcher", "physcannon", + "crowbar", nullptr}; + for (int i = 0; names[i]; i++) + if (active_weapon_contains(local_ent, names[i])) + return true; + return false; +} + +static bool fire_edge_ok(CUserCmd *cmd) { + void *local_ent = entitylist_get(engine_get_local_player()); + if (!local_ent || !weapon_is_semi_auto(local_ent)) + return true; + static int last_tick = -1; + static bool phase = false; + if (cmd->tick_count != last_tick) { + last_tick = cmd->tick_count; + phase = !phase; + } + return phase; +} + bool aimbot_apply(CUserCmd *cmd) { unsigned bone_mask = aimbot_bone_mask(); if (!bone_mask) @@ -1080,7 +1364,8 @@ bool aimbot_apply(CUserCmd *cmd) { "aimbot", lock, target, point, fov)) return false; Vector lead; - if (projectile_lead_point(target, point, lead)) + if (projectile_lead_point(target, point, lead) && + lead_point_ok(target, lead, view, max_fov)) point = lead; QAngle desired = angle_to(g_cam_eye, point); if (punch) { @@ -1168,7 +1453,8 @@ bool silent_aim_apply(CUserCmd *cmd) { "silentaim", lock, target, point, fov)) return false; Vector lead; - if (projectile_lead_point(target, point, lead)) + if (projectile_lead_point(target, point, lead) && + lead_point_ok(target, lead, view, max_fov)) point = lead; QAngle desired = angle_to(g_cam_eye, point); if (punch) { @@ -1196,7 +1482,8 @@ bool silent_aim_apply(CUserCmd *cmd) { using SetViewFn = void (*)(void *, QAngle *); vfunc(g_iface.engine, 20)(g_iface.engine, &restore); } - cmd->buttons |= IN_ATTACK; + if (fire_edge_ok(cmd)) + cmd->buttons |= IN_ATTACK; static int logs = 0; if (logs < 5) { logs++; @@ -1379,7 +1666,7 @@ bool melee_aimbot_apply(CUserCmd *cmd, bool gravity) { if (!ent) continue; void *net = entity_networkable(idx); - if (!net) + if (!net || entity_is_dormant(net)) continue; const char *cls = entity_class_name(net, nullptr); if (!cls || !is_npc_class(cls)) @@ -1417,15 +1704,17 @@ bool melee_aimbot_apply(CUserCmd *cmd, bool gravity) { maxy = *(float *)(e + g_net.maxs + 4); maxz = *(float *)(e + g_net.maxs + 8); } - if (!(maxz > minz + 4.0f && maxz < 512.0f)) { + // Reject absurd boxes in both directions; a garbage mins value that is + // hugely negative would otherwise produce an AABB spanning the map. + if (!(minz > -512.0f && maxz > minz + 4.0f && maxz < 512.0f)) { minz = 0.0f; maxz = 72.0f; } - if (!(maxx > minx + 1.0f && maxx < 256.0f)) { + if (!(minx > -512.0f && maxx > minx + 1.0f && maxx < 256.0f)) { minx = -16.0f; maxx = 16.0f; } - if (!(maxy > miny + 1.0f && maxy < 256.0f)) { + if (!(miny > -512.0f && maxy > miny + 1.0f && maxy < 256.0f)) { miny = -16.0f; maxy = 16.0f; } @@ -1529,7 +1818,8 @@ bool melee_aimbot_apply(CUserCmd *cmd, bool gravity) { } // Gravity gun primary attack launches the held object (EyeVectors); // secondary would drop it instead. - cmd->buttons |= IN_ATTACK; + if (fire_edge_ok(cmd)) + cmd->buttons |= IN_ATTACK; static int logs = 0; if (logs < 10) { @@ -1572,9 +1862,9 @@ static void spread_compute() { SeedFn seed = (SeedFn)dlsym(RTLD_DEFAULT, "RandomSeed"); FloatFn rf = (FloatFn)dlsym(RTLD_DEFAULT, "RandomFloat"); if (!seed || !rf) { - size_t slash = g_engine.path.find_last_of('/'); + size_t slash = mod_engine().path.find_last_of('/'); if (slash != std::string::npos) { - std::string path = g_engine.path.substr(0, slash + 1) + "libvstdlib.so"; + std::string path = mod_engine().path.substr(0, slash + 1) + "libvstdlib.so"; void *h = dlopen(path.c_str(), RTLD_NOLOAD | RTLD_LAZY | RTLD_GLOBAL); if (!h) h = dlopen(path.c_str(), RTLD_LAZY | RTLD_GLOBAL); @@ -1725,38 +2015,49 @@ bool triggerbot_apply(CUserCmd *cmd) { if (!hit) return false; - using Fn = int (*)(void *); - int hi = vfunc(g_iface.entitylist, 6)(g_iface.entitylist); - if (hi < 0 || hi > 4096) - hi = 4096; - for (int idx = 1; idx <= hi; idx++) { - if (entitylist_get(idx) != hit) - continue; - void *net = entity_networkable(idx); - const char *cls = net ? entity_class_name(net, nullptr) : nullptr; - if (!cls || !is_npc_class(cls)) - return false; - uintptr_t e = (uintptr_t)hit; - int life = g_net.lifestate >= 0 - ? (int)*(unsigned char *)(e + g_net.lifestate) - : -1; - if (life != LIFE_ALIVE) - return false; - const char *model = entity_model_name(hit); - bool neutral = false; - if (npc_is_friendly(idx, model, local, neutral) || neutral) - return false; - if (!npc_can_be_damaged(idx, model)) + int idx = entptr_lookup(hit); + if (idx <= 0) { + // Cache miss: ESP is off, or this entity appeared since the last scan. + using Fn = int (*)(void *); + int hi = vfunc(g_iface.entitylist, 6)(g_iface.entitylist); + if (hi < 0 || hi > 4096) + hi = 4096; + for (int i = 1; i <= hi; i++) { + if (entitylist_get(i) != hit) + continue; + idx = i; + entptr_store(hit, i); + break; + } + if (idx <= 0) return false; + } + void *net = entity_networkable(idx); + if (entity_is_dormant(net)) + return false; + const char *cls = net ? entity_class_name(net, nullptr) : nullptr; + if (!cls || !is_npc_class(cls)) + return false; + uintptr_t e = (uintptr_t)hit; + int life = g_net.lifestate >= 0 + ? (int)*(unsigned char *)(e + g_net.lifestate) + : -1; + if (life != LIFE_ALIVE) + return false; + const char *model = entity_model_name(hit); + bool neutral = false; + if (npc_is_friendly(idx, model, local, neutral) || neutral) + return false; + if (!npc_can_be_damaged(idx, model)) + return false; + if (fire_edge_ok(cmd)) cmd->buttons |= IN_ATTACK; - static int logs = 0; - if (logs < 5) { - logs++; - logd("triggerbot: fire idx=%d model=%s", idx, model ? model : "?"); - } - return true; + static int logs = 0; + if (logs < 5) { + logs++; + logd("triggerbot: fire idx=%d model=%s", idx, model ? model : "?"); } - return false; + return true; } // Dynamic props are tinted through their client render colour. StudioRender @@ -2075,13 +2376,8 @@ static void draw_transitions() { void esp_draw() { if (!g_surface || !g_iface.entitylist || !g_net.esp_ok()) return; - if (!g_scr_w) { - using Fn = void (*)(void *, int &, int &); - vfunc(g_surface, SURF_GetScreenSize)(g_surface, g_scr_w, g_scr_h); - logd("ESP: screen %dx%d", g_scr_w, g_scr_h); - if (!g_scr_w) return; - } - draw_fov_circle(); + if (!esp_update_screen()) + return; esp_collect(engine_get_local_player()); static double last_trans = 0.0; double trans_now = esp_now(); @@ -2805,7 +3101,7 @@ static void items_collect(int local_idx) { if (!ent) continue; void *net = entity_networkable(idx); - if (!net) + if (!net || entity_is_dormant(net)) continue; const char *cls = entity_class_name(net, nullptr); const char *scls = nullptr; @@ -2922,12 +3218,8 @@ void esp_draw_items() { int local = engine_get_local_player(); if (local <= 0) return; - if (!g_scr_w) { - using Fn = void (*)(void *, int &, int &); - vfunc(g_surface, SURF_GetScreenSize)(g_surface, g_scr_w, g_scr_h); - if (!g_scr_w) - return; - } + if (!esp_update_screen()) + return; items_collect(local); int vit_hp = -1, vit_max = -1, vit_armor = -1; @@ -2949,7 +3241,11 @@ void esp_draw_items() { } for (int i = 0; i < g_nitems; i++) { - const ItemTarget &t = g_item_targets[i]; + ItemTarget &t = g_item_targets[i]; + // Follow the entity between the 10 Hz discovery scans. + Vector live; + if (entity_origin(entitylist_get(t.idx), live)) + t.origin = live; if (!item_kind_enabled(t.kind)) continue; // Dispensers (chargers, ammo crates, supply crates) stay useful after a @@ -3058,17 +3354,23 @@ static const char *proj_name(int kind) { // Fallback fuse clock when the server detonate time cannot be read. The stock // frag fuse is 3 s from the first sighting. static double proj_first_seen(int idx, double now) { - static int idxs[32]; - static double first[32]; - static int n = 0; - for (int i = 0; i < n; i++) - if (idxs[i] == idx) - return first[i]; - if (n < 32) { - idxs[n] = idx; - first[n] = now; - n++; - } + static constexpr int N = 64; + static int idxs[N]; + static double first[N]; + static int next = 0; + for (int i = 0; i < N; i++) { + if (idxs[i] != idx) + continue; + // The slot is stale if this index was last seen long ago: a new grenade + // reusing the entity number must start its own fuse, not inherit an old + // one (which produced permanently negative fuses). + if (now - first[i] > 10.0) + first[i] = now; + return first[i]; + } + idxs[next] = idx; + first[next] = now; + next = (next + 1) % N; // oldest entry is reused instead of dropped return now; } @@ -3094,7 +3396,7 @@ static void projs_collect(int local_idx) { if (!ent) continue; void *net = entity_networkable(idx); - if (!net) + if (!net || entity_is_dormant(net)) continue; const char *cls = entity_class_name(net, nullptr); const char *model = entity_model_name(ent); @@ -3171,6 +3473,8 @@ static void draw_grenade_trajectory(int local_idx) { Vector src{}, vel{}; bool have = false; bool actual = false; + // Real blast radius of the grenade being previewed, not a hardcoded 300. + float blast_radius = 300.0f; // Prefer the local player's thrown frag: the real position and velocity are // networked, so the preview starts from what is actually flying instead of // the pre-throw estimate. The bounce contact points still depend on the @@ -3182,6 +3486,13 @@ static void draw_grenade_trajectory(int local_idx) { if (!gent) continue; src = g_projs[i].origin; + // Live position: the cached origin is up to 100 ms old, and the velocity + // read below is live, so mixing them desynced the arc from the grenade. + Vector live; + if (entity_origin(gent, live)) + src = live; + if (g_projs[i].radius > 1.0f) + blast_radius = g_projs[i].radius; if (g_net.velocity >= 0) { const float *pv = (const float *)((uintptr_t)gent + g_net.velocity); vel = Vector{pv[0], pv[1], pv[2]}; @@ -3277,9 +3588,11 @@ static void draw_grenade_trajectory(int local_idx) { surf_line(x, y - 9, x, y - 4); surf_line(x, y + 4, x, y + 9); } - if (landed) { + // The landing ring belongs to the "Blast radius" toggle, and uses the + // networked m_DmgRadius when available instead of a fixed 300. + if (landed && g_proj_radius) { const int SEG = 36; - const float radius = 300.0f; // CBaseGrenade m_DmgRadius + const float radius = blast_radius; surf_color(255, 120, 40, 120); for (int k = 0; k < SEG; k++) { float a0 = (float)k * 2.0f * PI_F / (float)SEG; @@ -3298,16 +3611,17 @@ static void draw_grenade_trajectory(int local_idx) { void esp_draw_projectiles() { if (!g_surface || !g_iface.entitylist || g_net.origin < 0) return; - if (!g_scr_w) { - using Fn = void (*)(void *, int &, int &); - vfunc(g_surface, SURF_GetScreenSize)(g_surface, g_scr_w, g_scr_h); - if (!g_scr_w) - return; - } + if (!esp_update_screen()) + return; projs_collect(engine_get_local_player()); draw_grenade_trajectory(engine_get_local_player()); for (int i = 0; i < g_nprojs; i++) { - const ProjTarget &p = g_projs[i]; + ProjTarget &p = g_projs[i]; + // Follow the entity between the 10 Hz discovery scans so the marker and + // the blast-radius ring do not step in 100 ms increments. + Vector live; + if (entity_origin(entitylist_get(p.idx), live)) + p.origin = live; int r = 255, g = 170, b = 60; if (p.kind == PK_RPG) { r = 255; g = 120; b = 40; diff --git a/src/gui.cpp b/src/gui.cpp index 454e2cb..8a71ecb 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -19,20 +19,8 @@ // ISurface drawing (same binary slots as menu.cpp / esp.cpp) // --------------------------------------------------------------------------- -enum { - GS_DrawSetColor = 10, - GS_DrawFilledRect = 12, - GS_DrawOutlinedRect = 14, - GS_DrawLine = 15, - GS_DrawSetTextFont = 17, - GS_DrawSetTextColor = 18, - GS_DrawSetTextPos = 20, - GS_DrawPrintText = 22, - GS_GetScreenSize = 38, - GS_CreateFont = 66, - GS_AddGlyphSet = 67, - GS_GetTextSize = 75 -}; +// ISurface access lives in overlay.cpp. These thin wrappers keep the GUI code +// readable without repeating the vtable indices in this file. static unsigned long g_font_body = 0; static unsigned long g_font_title = 0; @@ -43,57 +31,27 @@ static void *g_font_local = (void *)-1; static bool g_font_fail_logged = false; static int g_scr_w = 0, g_scr_h = 0; -static void s_color(int r, int g, int b, int a) { - using Fn = void (*)(void *, int, int, int, int); - vfunc(g_surface, GS_DrawSetColor)(g_surface, r, g, b, a); -} +static void s_color(int r, int g, int b, int a) { overlay_color(r, g, b, a); } static void s_fill(int x0, int y0, int x1, int y1) { - using Fn = void (*)(void *, int, int, int, int); - vfunc(g_surface, GS_DrawFilledRect)(g_surface, x0, y0, x1, y1); -} -static void s_outline(int x0, int y0, int x1, int y1) { - using Fn = void (*)(void *, int, int, int, int); - vfunc(g_surface, GS_DrawOutlinedRect)(g_surface, x0, y0, x1, y1); + overlay_fill(x0, y0, x1, y1); } static void s_line(int x0, int y0, int x1, int y1) { - using Fn = void (*)(void *, int, int, int, int); - vfunc(g_surface, GS_DrawLine)(g_surface, x0, y0, x1, y1); -} -static void s_text_font(unsigned long f) { - using Fn = void (*)(void *, unsigned long); - vfunc(g_surface, GS_DrawSetTextFont)(g_surface, f); + overlay_line(x0, y0, x1, y1); } +static void s_text_font(HFont f) { overlay_text_font(f); } static void s_text_color(int r, int g, int b, int a) { - using Fn = void (*)(void *, int, int, int, int); - vfunc(g_surface, GS_DrawSetTextColor)(g_surface, r, g, b, a); -} -static void s_text_pos(int x, int y) { - using Fn = void (*)(void *, int, int); - vfunc(g_surface, GS_DrawSetTextPos)(g_surface, x, y); + overlay_text_color(r, g, b, a); } -static void s_print(const wchar_t *text, int n) { - using Fn = void (*)(void *, const wchar_t *, int, int); - vfunc(g_surface, GS_DrawPrintText)(g_surface, text, n, 0); -} -static unsigned long s_create_font() { - using Fn = unsigned long (*)(void *); - return vfunc(g_surface, GS_CreateFont)(g_surface); -} -static bool s_set_font(unsigned long font, const char *name, int tall, int weight, +static void s_text_pos(int x, int y) { overlay_text_pos(x, y); } +static void s_print(const wchar_t *text, int n) { overlay_print_text(text, n); } +static HFont s_create_font() { return overlay_create_font(); } +static bool s_set_font(HFont font, const char *name, int tall, int weight, int flags, int lo, int hi) { - using Fn = bool (*)(void *, unsigned long, const char *, int, int, int, int, - int, int, int); - return vfunc(g_surface, GS_AddGlyphSet)(g_surface, font, name, tall, - weight, 0, 0, flags, lo, hi); -} -static void s_screen(int &w, int &h) { - using Fn = void (*)(void *, int &, int &); - vfunc(g_surface, GS_GetScreenSize)(g_surface, w, h); + return overlay_add_glyph_set(font, name, tall, weight, 0, 0, flags, lo, hi); } -static void s_text_size(unsigned long font, const wchar_t *text, int &w, - int &h) { - using Fn = void (*)(void *, unsigned long, const wchar_t *, int &, int &); - vfunc(g_surface, GS_GetTextSize)(g_surface, font, text, w, h); +static void s_screen(int &w, int &h) { overlay_screen_size(w, h); } +static void s_text_size(HFont font, const wchar_t *text, int &w, int &h) { + overlay_text_size(font, text, w, h); } // --------------------------------------------------------------------------- @@ -137,9 +95,12 @@ static void gui_init_sdl() { g_sdl_init_tried = true; sd_get_keys = (GetKeysFn)dlsym(RTLD_DEFAULT, "SDL_GetKeyboardState"); sd_get_mouse = (GetMouseFn)dlsym(RTLD_DEFAULT, "SDL_GetMouseState"); - sd_show_cursor = (ShowCursorFn)dlsym(RTLD_DEFAULT, "SDL_ShowCursor"); - sd_set_relative = (SetRelativeFn)dlsym(RTLD_DEFAULT, "SDL_SetRelativeMouseMode"); - sd_get_relative = (GetRelativeFn)dlsym(RTLD_DEFAULT, "SDL_GetRelativeMouseMode"); + // RTLD_NEXT, not RTLD_DEFAULT: this object is LD_PRELOADed first, so + // RTLD_DEFAULT resolves to our own exported wrapper and the "real" pointer + // would point back into this file. + sd_show_cursor = (ShowCursorFn)dlsym(RTLD_NEXT, "SDL_ShowCursor"); + sd_set_relative = (SetRelativeFn)dlsym(RTLD_NEXT, "SDL_SetRelativeMouseMode"); + sd_get_relative = (GetRelativeFn)dlsym(RTLD_NEXT, "SDL_GetRelativeMouseMode"); sd_add_watch = (AddWatchFn)dlsym(RTLD_DEFAULT, "SDL_AddEventWatch"); sd_get_ticks = (GetTicksFn)dlsym(RTLD_DEFAULT, "SDL_GetTicks"); g_sdl_ready = sd_get_keys && sd_get_mouse; @@ -607,6 +568,10 @@ static int g_mouse_press = 0; // 1..5 on the frame a button goes down static unsigned char g_prev_keys[512] = {}; static bool g_prev_cursor = true; static bool g_prev_relative = true; +// Last state the game asked for while the GUI held the mouse; restored on +// close instead of the (possibly stale) pre-open snapshot. +static bool g_game_wants_cursor = true; +static bool g_game_wants_relative = false; static double g_hover_since = 0.0; static const char *g_hover_desc = nullptr; static const char *g_hover_prev = nullptr; @@ -688,10 +653,12 @@ static unsigned long create_gui_font(int tall) { static const char *families[] = {"Liberation Sans", "DejaVu Sans", "Noto Sans", "Tahoma", "Verdana", "Arial", nullptr}; + // Create one handle and add glyph sets to it. Creating a handle per family + // leaked one HFont per failed family, and ISurface has no DestroyFont. + unsigned long f = s_create_font(); + if (!f) + return 0; for (int i = 0; families[i]; i++) { - unsigned long f = s_create_font(); - if (!f) - break; if (s_set_font(f, families[i], tall, 400, 0x400, 0x20, 0xFF) && font_valid(f)) { logd("gui: font %dpx family %s", tall, families[i]); @@ -710,8 +677,13 @@ static void ensure_fonts() { } if (le != g_font_local) { g_font_local = le; - gui_reset_fonts(); - logd("gui: fonts reset (local entity change)"); + // VGUI fonts are global; a level change alone does not invalidate them. + // Dropping valid handles here leaked a fresh pair on every death, respawn + // and map load. Only reset when the manager actually lost them. + if (!font_valid(g_font_body) || !font_valid(g_font_title)) { + gui_reset_fonts(); + logd("gui: fonts reset (invalid after local entity change)"); + } } bool scale_changed = fabsf(g_font_scale - g_scale) >= 0.001f; @@ -871,20 +843,30 @@ static void draw_text_raw(int x, int y, const char *s, unsigned long font, static void text_in(const Rect &clip, int x, int y, const char *s, unsigned long font, int tall, int max_w, const Col &c) { - (void)max_w; // callers must size the layout; text is never truncated if (!s || !font) return; if (y < clip.y || y + tall > clip.y + clip.h) return; if (x >= clip.x + clip.w) return; - int w = text_w(s, tall); - if (x + w > clip.x + clip.w) - x = clip.x + clip.w - w; - if (x < clip.x) - x = clip.x; + // Available width: the clip edge, further limited by the layout's column + // width. Overflowing text used to be shifted left over its neighbour; now it + // is truncated with an ellipsis instead. + int avail = clip.x + clip.w - x; + if (max_w > 0 && max_w < avail) + avail = max_w; + if (avail <= 0) + return; + std::string out = s; + if (text_w(out.c_str(), tall) > avail) { + while (!out.empty() && text_w((out + "...").c_str(), tall) > avail) + out.pop_back(); + if (out.empty()) + return; + out += "..."; + } s_text_color(c.r, c.g, c.b, c.a); - draw_text_raw(x, y, s, font, tall); + draw_text_raw(x, y, out.c_str(), font, tall); } static Rect clip_rect(const Rect &r, const Rect &clip) { @@ -911,13 +893,49 @@ static void fill(const Rect &r, const Col &c, const Rect *clip = nullptr) { static void outline(const Rect &r, const Col &c, const Rect *clip = nullptr) { if (r.w <= 0 || r.h <= 0) return; - Rect q = clip ? clip_rect(r, *clip) : r; - if (q.w <= 0 || q.h <= 0) - return; - s_color(c.r, c.g, c.b, c.a); - s_outline(q.x, q.y, q.x + q.w, q.y + q.h); -} -static void line(int x0, int y0, int x1, int y1, const Col &c) { + // Draw the four edges as thin filled rects. Outlining the *clamped* rect used + // to paint a false border along the clip edge whenever a card was partially + // scrolled out of the content pane. + int t = S(1.0f); + if (t < 1) t = 1; + fill(Rect{r.x, r.y, r.w, t}, c, clip); + fill(Rect{r.x, r.y + r.h - t, r.w, t}, c, clip); + fill(Rect{r.x, r.y, t, r.h}, c, clip); + fill(Rect{r.x + r.w - t, r.y, t, r.h}, c, clip); +} +// Liang–Barsky line clip. `clip` is optional so the existing call sites that +// draw inside an already-safe area keep working unchanged. +static void line(int x0, int y0, int x1, int y1, const Col &c, + const Rect *clip = nullptr) { + if (clip) { + float dx = (float)(x1 - x0), dy = (float)(y1 - y0); + float t0 = 0.0f, t1 = 1.0f; + float p[4] = {-dx, dx, -dy, dy}; + float q[4] = {(float)(x0 - clip->x), + (float)(clip->x + clip->w - x0), + (float)(y0 - clip->y), + (float)(clip->y + clip->h - y0)}; + for (int i = 0; i < 4; i++) { + if (fabsf(p[i]) < 1e-6f) { + if (q[i] < 0.0f) + return; // parallel and outside + continue; + } + float t = q[i] / p[i]; + if (p[i] < 0.0f) { + if (t > t1) return; + if (t > t0) t0 = t; + } else { + if (t < t0) return; + if (t < t1) t1 = t; + } + } + int nx0 = (int)((float)x0 + t0 * dx + 0.5f); + int ny0 = (int)((float)y0 + t0 * dy + 0.5f); + int nx1 = (int)((float)x0 + t1 * dx + 0.5f); + int ny1 = (int)((float)y0 + t1 * dy + 0.5f); + x0 = nx0; y0 = ny0; x1 = nx1; y1 = ny1; + } s_color(c.r, c.g, c.b, c.a); s_line(x0, y0, x1, y1); } @@ -1298,7 +1316,7 @@ static void draw_picker(const Rect &clip, const Rect &cr, const Ctrl &ct, outline(p.alpha, Col{0, 0, 0, 255}, &clip); int amx = p.alpha.x + (int)((float)pa / 255.0f * (float)p.alpha.w); line(amx, p.alpha.y - S(2.0f), amx, p.alpha.y + p.alpha.h + S(2.0f), - Col{255, 255, 255, 255}); + Col{255, 255, 255, 255}, &clip); } // Selection markers. @@ -1311,7 +1329,7 @@ static void draw_picker(const Rect &clip, const Rect &cr, const Ctrl &ct, int hxm = p.hue.x + (int)((ct.hue / 360.0f) * (float)p.hue.w); line(hxm, p.hue.y - S(2.0f), hxm, p.hue.y + p.hue.h + S(2.0f), - Col{255, 255, 255, 255}); + Col{255, 255, 255, 255}, &clip); // Preview swatch and hex value. draw_checker(p.preview, &clip); @@ -1351,6 +1369,23 @@ static Rect card_rect(int index, int y) { return Rect{x, y, w, card_h(c)}; } +// Slider track inside a card: the single source of truth for drawing, +// hit-testing and dragging. The drag path used to recompute this by hand and +// omitted the card's +S(10) left margin, so the value jumped on mouse-down. +static Rect slider_track(const Rect &cr, int y, int rh) { + int w = cr.w - S(16.0f) - S(230.0f) - S(64.0f); + if (w < S(30.0f)) w = S(30.0f); + return Rect{cr.x + S(16.0f) + S(230.0f), y + (rh - S(8.0f)) / 2, w, + S(8.0f)}; +} + +static int card_for_ctrl(int ci) { + for (int i = 0; i < g_ncards; i++) + if (ci >= g_cards[i].c0 && ci < g_cards[i].c0 + g_cards[i].n) + return i; + return -1; +} + static bool card_in_category(int i) { return g_cards[i].cat == g_cat; } static int content_height() { @@ -1379,10 +1414,13 @@ static void close_gui() { g_reset_armed = false; input_clear_mouse_states(); if (g_sdl_ready) { + // Restore what the game actually wants now, not the pre-open snapshot: the + // game keeps requesting relative mode/cursor state while the GUI is open + // (menu -> map transition), and those requests were being dropped. if (sd_set_relative) - sd_set_relative(g_prev_relative ? 1 : 0); + sd_set_relative(g_game_wants_relative ? 1 : 0); if (sd_show_cursor) - sd_show_cursor(g_prev_cursor ? 1 : 0); + sd_show_cursor(g_game_wants_cursor ? 1 : 0); } config_save(); logf("gui: closed"); @@ -1410,6 +1448,9 @@ static void open_gui() { g_prev_relative = sd_get_relative() != 0; if (sd_show_cursor) g_prev_cursor = sd_show_cursor(-1) != 0; + // Until the game asks for something else, restore what it had before. + g_game_wants_relative = g_prev_relative; + g_game_wants_cursor = g_prev_cursor; if (sd_set_relative) sd_set_relative(0); if (sd_show_cursor) @@ -1519,6 +1560,10 @@ static void handle_mouse_click() { } } g_dropdown_ctrl = -1; + // Consume the dismissal click: it must not also activate whatever control + // happened to be underneath the popup. + g_mouse_click = false; + return; } // title bar: drag @@ -1624,9 +1669,7 @@ static void handle_mouse_click() { return; } if (ct.kind == CK_SLIDER) { - Rect track{cr.x + S(16.0f) + S(230.0f), - by + (rh - S(8.0f)) / 2, - cr.w - S(16.0f) - S(230.0f) - S(64.0f), S(8.0f)}; + Rect track = slider_track(cr, by, rh); if (mx >= track.x && mx < track.x + track.w) { float t = (float)(mx - track.x) / (float)track.w; int v = ct.min + (int)(t * (ct.max - ct.min) + 0.5f); @@ -1717,6 +1760,11 @@ static void handle_mouse_click() { static void handle_hover() { g_hover_desc = nullptr; g_hover_slider = -1; + // An open popup/picker or an active drag owns the cursor; arming hover on the + // controls underneath let Left/Right arrows adjust a hidden slider. + if (g_dropdown_ctrl >= 0 || g_picker_ctrl >= 0 || g_picker_drag || + g_slider_drag >= 0 || g_dragging || g_scroll_drag) + return; int mx = g_mouse_x, my = g_mouse_y; Rect content{g_win_x + S(190.0f), g_win_y + S(38.0f), g_win_w - S(190.0f), g_win_h - S(38.0f) - S(30.0f)}; @@ -1849,14 +1897,15 @@ void gui_handle_input() { config_save(); } else { Ctrl &ct = g_ctrls[g_slider_drag]; - Rect content{g_win_x + S(190.0f), g_win_y + S(38.0f), - g_win_w - S(190.0f), g_win_h - S(38.0f) - S(30.0f)}; - int x0 = g_win_x + S(190.0f) + S(16.0f) + S(230.0f); - int x1 = g_win_x + g_win_w - S(28.0f) - S(64.0f); - (void)content; - float t = (float)(g_mouse_x - x0) / (float)(x1 - x0); - int v = ct.min + (int)(t * (ct.max - ct.min) + 0.5f); - ctrl_set(ct, v); + int ci = card_for_ctrl(g_slider_drag); + if (ci >= 0) { + Rect track = slider_track(card_rect(ci, 0), 0, 0); + float t = (float)(g_mouse_x - track.x) / (float)track.w; + if (t < 0.0f) t = 0.0f; + if (t > 1.0f) t = 1.0f; + int v = ct.min + (int)(t * (ct.max - ct.min) + 0.5f); + ctrl_set(ct, v); + } } } @@ -1938,18 +1987,15 @@ static void draw_checkbox(int x, int y, bool on, fill(box, on ? C_ACCENT : Col{30, 34, 42, 255}, clip); outline(box, C_BORDER, clip); if (on) { - line(x + S(3.0f), y + S(8.0f), x + S(7.0f), y + S(12.0f), C_BG); - line(x + S(7.0f), y + S(12.0f), x + S(13.0f), y + S(4.0f), C_BG); + line(x + S(3.0f), y + S(8.0f), x + S(7.0f), y + S(12.0f), C_BG, clip); + line(x + S(7.0f), y + S(12.0f), x + S(13.0f), y + S(4.0f), C_BG, clip); } } static void draw_slider(const Rect &clip, const Rect &cr, int y, int rh, const Ctrl &ct) { - int track_x = cr.x + S(16.0f) + S(230.0f); - int track_w = cr.w - S(16.0f) - S(230.0f) - S(64.0f); - if (track_w < S(30.0f)) track_w = S(30.0f); - int track_y = y + (rh - S(8.0f)) / 2; - Rect track{track_x, track_y, track_w, S(8.0f)}; + Rect track = slider_track(cr, y, rh); + int track_w = track.w; fill(track, Col{38, 42, 50, 255}, &clip); int value = ctrl_val(ct); float t = (ct.max > ct.min) @@ -2416,6 +2462,8 @@ extern "C" __attribute__((visibility("default"))) int SDL_ShowCursor(int toggle) if (!real_show_cursor) real_show_cursor = (int (*)(int))dlsym(RTLD_NEXT, "SDL_ShowCursor"); if (g_open && toggle != -1) { + // Remember what the game asked for so close_gui() can restore it. + g_game_wants_cursor = toggle != 0; if (real_show_cursor) real_show_cursor(1); return 1; @@ -2429,6 +2477,7 @@ SDL_SetRelativeMouseMode(int enabled) { real_set_relative = (int (*)(int))dlsym(RTLD_NEXT, "SDL_SetRelativeMouseMode"); if (g_open) { + g_game_wants_relative = enabled != 0; if (real_set_relative) real_set_relative(0); return 0; @@ -2520,7 +2569,11 @@ SDL_PollEvent(void *event) { unsigned int type = *(unsigned int *)event; if (type != 0x400) // SDL_MOUSEMOTION return r; - // Drop motion while the GUI is open: the game must not accumulate look - // deltas. The GUI uses absolute SDL_GetMouseState polling. + // Keep the event (VGUI hover and SDL's internal cursor state need it) but + // zero the relative deltas so the game does not accumulate look movement. + // SDL_MouseMotionEvent: xrel at +28, yrel at +32. + *(int *)((char *)event + 28) = 0; + *(int *)((char *)event + 32) = 0; + return r; } } diff --git a/src/hl2h.cpp b/src/hl2h.cpp index ddfc02a..6697778 100644 --- a/src/hl2h.cpp +++ b/src/hl2h.cpp @@ -1,11 +1,47 @@ // hl2h.cpp — core: logging, vector math, globals, feature logic, setup #include "hl2h.h" +#include #include #include #include #include #include +// --------------------------------------------------------------------------- +// init readiness +// --------------------------------------------------------------------------- + +static std::atomic g_init_stage{INIT_NONE}; + +void init_set_stage(int stage) { + g_init_stage.store(stage, std::memory_order_release); +} + +int init_stage() { return g_init_stage.load(std::memory_order_acquire); } + +// --------------------------------------------------------------------------- +// vtable cloning +// --------------------------------------------------------------------------- +// +// Under the Itanium C++ ABI the two words before slot 0 are offset-to-top and +// the type_info pointer. Copying only the slots and repointing the object's +// vptr leaves those words pointing at allocator metadata, which breaks any +// RTTI the host performs on the interface. Keep the header words with the +// clone and hand back slot 0. + +void **vtable_clone(void *iface, size_t slots) { + if (!iface || slots == 0) + return nullptr; + void **vt = *(void ***)iface; + if (!vt) + return nullptr; + void **out = new void *[slots + 2]; + out[0] = vt[-2]; + out[1] = vt[-1]; + memcpy(out + 2, vt, slots * sizeof(void *)); + return out + 2; +} + // --------------------------------------------------------------------------- // logging // --------------------------------------------------------------------------- @@ -67,7 +103,6 @@ void logd(const char *fmt, ...) { // globals // --------------------------------------------------------------------------- -Module g_client, g_engine, g_server; GameInterfaces g_iface; NetVars g_net; Vector g_cam_eye{0, 0, 0}; @@ -87,11 +122,11 @@ QAngle vector_angles(const Vector &forward) { a.y = atan2f(forward.y, forward.x) * 180.f / PI_F; if (a.y < 0) a.y += 360.f; + // Pitch stays in [-90, 90] like Source's QAngle; wrapping negatives to + // [270, 360) made every upward aim compare as a steeply negative angle. a.x = atan2f(-forward.z, sqrtf(forward.x * forward.x + forward.y * forward.y)) * 180.f / PI_F; - if (a.x < 0) - a.x += 360.f; } return a; } @@ -485,7 +520,31 @@ static bool g_seed_hook_installed = false; // Pellet-spread window armed by the player's FireBullets RandomSeed call. static volatile int g_spread_rand_left = 0; +// Identify the caller of a random-stream function by reading the caller's +// stack frame. The callers live in Valve's binaries, which are not built with +// the guarantee our own -fno-omit-frame-pointer gives us, so treat the walk as +// untrusted: it may only stay inside the current stack, and the object it +// yields is used for nothing but comparison against g_local_server_player. +static void *caller_self_arg() { + void *sp = nullptr, *bp = nullptr; + __asm__ volatile("movl %%esp, %0" : "=r"(sp)); + __asm__ volatile("movl %%ebp, %0" : "=r"(bp)); + uintptr_t spv = (uintptr_t)sp, bpv = (uintptr_t)bp; + constexpr uintptr_t MAX_DEPTH = 0x100000; // 1 MiB of stack frames + if (!bpv || (bpv & 3) || bpv <= spv || bpv - spv > MAX_DEPTH) + return nullptr; + uintptr_t caller = *(uintptr_t *)bpv; + if (!caller || (caller & 3) || caller < bpv || caller - spv > MAX_DEPTH) + return nullptr; + return *(void **)(caller + 8); // first argument slot of the caller +} + static void hkdSrvRandomSeed(int seed) { + if (!init_live()) { + if (g_real_random_seed) + g_real_random_seed(seed); + return; + } uintptr_t ret = (uintptr_t)__builtin_return_address(0); long off = (long)(ret - g_srv_base); static bool trace = getenv("HL2H_SEED_TRACE") != nullptr; @@ -494,16 +553,16 @@ static void hkdSrvRandomSeed(int seed) { // The caller's first argument is `this`. Only FireBullets is called on the // player entity; the CUniformRandomStream wrappers and the constructor-time // RandomSeed(0) reset have a different object there. - void *frame = nullptr; - __asm__ volatile("movl %%ebp, %0" : "=r"(frame)); - if (frame) - frame = *(void **)frame; // caller's ebp (CBaseEntity::FireBullets) - void *self = frame ? *(void **)((char *)frame + 8) : nullptr; + void *self = caller_self_arg(); bool local = self && g_local_server_player && (uintptr_t)self == g_local_server_player; - if (local && g_server.contains(ret) && feature_active(FEAT_SPREAD) && - spread_no_spread_enabled() && spread_engine_enabled()) { + // Seed selection and engine spread zeroing are independent toggles: "engine + // override" only decides whether RandomFloat is forced to 0, it must not + // disable the seed hook (without the seed hook the server ignores + // cmd->random_seed entirely). + if (local && mod_server().contains(ret) && feature_active(FEAT_SPREAD) && + spread_no_spread_enabled()) { g_spread_rand_left = 8; int pellets = g_spread_pellets; if (pellets < 1) @@ -533,12 +592,12 @@ static void hkdSrvRandomSeed(int seed) { static void install_seed_hook() { static bool failed = false; - if (g_seed_hook_installed || failed || !g_server.valid()) + if (g_seed_hook_installed || failed || !mod_server().valid()) return; - g_srv_base = g_server.ranges[0].lo; + g_srv_base = mod_server().ranges[0].lo; uintptr_t reloc = 0, bias = 0; uintptr_t slot = 0; - if (module_jump_slot(g_server.path.c_str(), "RandomSeed", &reloc, &bias)) { + if (module_jump_slot(mod_server().path.c_str(), "RandomSeed", &reloc, &bias)) { slot = g_srv_base + reloc - bias; logf("seed hook: RandomSeed GOT reloc=%#lx bias=%#lx -> %#lx", (unsigned long)reloc, (unsigned long)bias, (unsigned long)slot); @@ -547,13 +606,13 @@ static void install_seed_hook() { static const uintptr_t known[] = {0xce5348, 0xce1348}; for (size_t i = 0; i < sizeof(known) / sizeof(known[0]) && !slot; i++) { uintptr_t cand = g_srv_base + known[i]; - if (g_server.contains(cand)) + if (mod_server().contains(cand)) slot = cand; } logf("seed hook: ELF reloc scan failed, fallback slot=%#lx", (unsigned long)slot); } - if (!slot || !g_server.contains(slot)) { + if (!slot || !mod_server().contains(slot)) { failed = true; logf("seed hook: no writable RandomSeed slot"); return; @@ -568,8 +627,15 @@ static void install_seed_hook() { toastf(TOAST_WARN, "Spread seed hook unavailable"); return; } - mprotect((void *)(slot & ~(uintptr_t)0xFFF), 0x1000, - PROT_READ | PROT_WRITE | PROT_EXEC); + // The slot is 4-byte aligned, so a single page always covers the 4-byte + // write. No PROT_EXEC: this is a data slot. Permissions are intentionally + // not restored because .got.plt stays writable for lazy binding. + uintptr_t page = slot & ~(uintptr_t)0xFFF; + if (mprotect((void *)page, 0x1000, PROT_READ | PROT_WRITE) != 0) { + failed = true; + logf("seed hook: mprotect failed for page %#lx", (unsigned long)page); + return; + } *(uint32_t *)slot = (uint32_t)(uintptr_t)&hkdSrvRandomSeed; g_seed_hook_installed = true; logf("seed hook: server GOT %#lx -> %p (real %p)", @@ -598,19 +664,16 @@ static bool g_uniform_hook_installed = false; // (the compiler may outline it), so this window is the reliable signal. static float hkdUniformRandomFloat(void *self, float mn, float mx) { + if (!init_live()) + return g_orig_uniform_float ? g_orig_uniform_float(self, mn, mx) : mn; bool enabled = g_local_server_player && feature_active(FEAT_SPREAD) && spread_no_spread_enabled() && spread_engine_enabled(); bool hit = false; void *who = nullptr; if (enabled) { - void *frame = nullptr; - __asm__ volatile("movl %%ebp, %0" : "=r"(frame)); - if (frame) { - frame = *(void **)frame; // caller's ebp - who = frame ? *(void **)((char *)frame + 8) : nullptr; - if (who && (uintptr_t)who == g_local_server_player) - hit = true; - } + who = caller_self_arg(); + if (who && (uintptr_t)who == g_local_server_player) + hit = true; if (!hit && g_spread_rand_left > 0) { // The next few draws after the player's FireBullets RandomSeed are the // pellet spread, regardless of which function emits them. @@ -748,9 +811,26 @@ static bool edge_jump_apply(CUserCmd *cmd, void *ent, int flags, return true; } +// Everything tied to one map load / local player lifetime. Called when the +// local entity pointer changes (map load, respawn, disconnect). +static void session_reset() { + bones_clear(); + aimbot_reset_lock(); + silentaim_reset_lock(); + esp_reset_session(); + fullbright_invalidate(); + world_tint_invalidate(); + fog_invalidate(); + skybox_invalidate(); + logd("session: reset (local entity changed)"); +} + static void do_move(int seq, float frametime) { install_seed_hook(); install_uniform_random_hook(); + // The pellet window is armed inside FireBullets during this tick; it must + // never survive into the next one. + g_spread_rand_left = 0; static bool g_echo_sent = false; if (!g_input || !g_net.ok()) return; @@ -779,17 +859,13 @@ static void do_move(int seq, float frametime) { view_settings_apply(ent); - // New map / respawn: drop stale bone caches and target locks. + // New map / respawn: drop stale caches and locks. Any per-map state must be + // cleared here; caches that are not listed will leak across level changes + // (that is how the server-health latch and the fuse table broke before). static void *last_local_ent = nullptr; if (ent != last_local_ent) { last_local_ent = ent; - bones_clear(); - aimbot_reset_lock(); - silentaim_reset_lock(); - fullbright_invalidate(); - world_tint_invalidate(); - fog_invalidate(); - skybox_invalidate(); + session_reset(); } if (feature_active(FEAT_SPREAD)) { @@ -854,8 +930,9 @@ static void do_move(int seq, float frametime) { if ((bhop_on || abh_on) && (cmd->buttons & IN_JUMP)) { if (inwater && (cmd->buttons & IN_DUCK)) { + // Not a bhop tick, but combat and jump assist still have to run. lastground = onground; - return; + goto bhop_done; } float speed = sqrtf(vel[0] * vel[0] + vel[1] * vel[1] + vel[2] * vel[2]); bool moving = speed > 0.1f; @@ -899,6 +976,7 @@ static void do_move(int seq, float frametime) { } } +bhop_done: bool jump_assist = crouch_jump_apply(cmd, flags, vel); if (edge_jump_apply(cmd, ent, flags, vel)) jump_assist = true; @@ -975,7 +1053,12 @@ void set_silent_restore(const QAngle &angles) { static void hkdFrameStageNotify(void *self, int stage) { if (oFrameStageNotify) oFrameStageNotify(self, stage); + if (!init_live()) + return; if (stage == 5) { + // FRAME_RENDER_START is the real frame boundary: it lets the paint hooks + // below draw exactly once per frame instead of once per VGUI panel. + overlay_frame_mark(); // Skybox texture uploads happen here, not in the VGUI paint hook: the // material system only downloads procedural textures on a render-safe // frame stage. @@ -1006,14 +1089,15 @@ static bool g_in_draw = false; // our overlay draws are submitted through outer_draw() (menu.cpp), which // wraps them in PushMakeCurrent/PopMakeCurrent with the embedded panel. static void hkdDrawPrintText(void *self, const wchar_t *text, int len, int drawType) { - if (g_in_draw) { + if (g_in_draw || !init_live()) { oDrawPrintText(self, text, len, drawType); return; } g_in_draw = true; oDrawPrintText(self, text, len, drawType); - // fallback trigger: fires when the game draws text (menus). Primary draw - // path is the PaintTraverseEx hook (slot 114), which fires every frame. + // Fallback trigger for frames where nothing paints (menus). The per-frame + // budget inside outer_draw() makes this a no-op if PaintTraverseEx already + // drew this frame. outer_draw(); g_in_draw = false; } @@ -1024,7 +1108,7 @@ static void hkdDrawPrintText(void *self, const wchar_t *text, int len, int drawT static void hkdPaintTraverseEx(void *self, long panel, bool paintPopups) { using Fn = void (*)(void *, long, bool); ((Fn)oPaintTraverseEx)(self, panel, paintPopups); - if (!g_in_draw) { + if (!g_in_draw && init_live()) { g_in_draw = true; outer_draw(); g_in_draw = false; @@ -1032,6 +1116,8 @@ static void hkdPaintTraverseEx(void *self, long panel, bool paintPopups) { } static bool hkdCreateMove(void *self, int seq, float frametime, bool active) { + if (!init_live()) + return oCreateMove(self, seq, frametime, active); bool ret = oCreateMove(self, seq, frametime, active); do_move(seq, frametime); @@ -1041,8 +1127,12 @@ static bool hkdCreateMove(void *self, int seq, float frametime, bool active) { if (!isurface_hooked && g_surface) { void **svt = VTABLE(g_surface); constexpr size_t VT_SIZE = 256; - void **newsvt = new void *[VT_SIZE]; - memcpy(newsvt, svt, VT_SIZE * sizeof(void *)); + void **newsvt = vtable_clone(g_surface, VT_SIZE); + if (!newsvt) { + isurface_hooked = 1; // don't retry every tick + logf("ISurface hook: vtable unavailable"); + return ret; + } oDrawPrintText = (DrawPrintTextFn)svt[22]; newsvt[22] = (void *)&hkdDrawPrintText; oPaintTraverseEx = (PaintTraverseExFn)svt[114]; @@ -1059,8 +1149,11 @@ static bool hkdCreateMove(void *self, int seq, float frametime, bool active) { static void install_hook() { void **vt = VTABLE(g_iface.client); constexpr size_t SLOTS = 256; - void **newvt = new void *[SLOTS]; - memcpy(newvt, vt, SLOTS * sizeof(void *)); + void **newvt = vtable_clone(g_iface.client, SLOTS); + if (!newvt) { + logf("hook install: client vtable unavailable"); + return; + } oCreateMove = (CreateMoveFn)vt[21]; newvt[21] = (void *)&hkdCreateMove; oFrameStageNotify = (FrameStageFn)vt[35]; @@ -1097,26 +1190,28 @@ static bool is_hl2_process() { static void *setup_thread(void *) { for (int i = 0; i < 6000; i++) { find_modules(); - if (g_client.valid() && g_engine.valid()) + if (mod_client().valid() && mod_engine().valid()) break; usleep(200 * 1000); } - if (!g_client.valid() || !g_engine.valid()) { + if (!mod_client().valid() || !mod_engine().valid()) { + init_set_stage(INIT_FAILED); logf("client.so/engine.so never appeared"); toastf(TOAST_ERROR, "client.so/engine.so not found; hl2hake inactive"); return nullptr; } - logf("client.so: %d mapped range(s), %s", (int)g_client.ranges.size(), - g_client.path.c_str()); - logf("engine.so: %d mapped range(s), %s", (int)g_engine.ranges.size(), - g_engine.path.c_str()); + logf("client.so: %d mapped range(s), %s", (int)mod_client().ranges.size(), + mod_client().path.c_str()); + logf("engine.so: %d mapped range(s), %s", (int)mod_engine().ranges.size(), + mod_engine().path.c_str()); // resolve factories via dlopen handles - void *ch = dlopen(g_client.path.c_str(), RTLD_NOLOAD | RTLD_LAZY | RTLD_GLOBAL); - void *eh = dlopen(g_engine.path.c_str(), RTLD_NOLOAD | RTLD_LAZY | RTLD_GLOBAL); + void *ch = dlopen(mod_client().path.c_str(), RTLD_NOLOAD | RTLD_LAZY | RTLD_GLOBAL); + void *eh = dlopen(mod_engine().path.c_str(), RTLD_NOLOAD | RTLD_LAZY | RTLD_GLOBAL); auto cf = (CreateInterfaceFn)dlsym(ch, "CreateInterface"); auto ef = (CreateInterfaceFn)dlsym(eh, "CreateInterface"); if (!cf || !ef) { + init_set_stage(INIT_FAILED); logf("CreateInterface not found"); toastf(TOAST_ERROR, "CreateInterface not found; hl2hake inactive"); return nullptr; @@ -1134,6 +1229,7 @@ static void *setup_thread(void *) { usleep(100 * 1000); } if (!g_iface.client || !g_iface.entitylist || !g_iface.engine) { + init_set_stage(INIT_FAILED); logf("interfaces unresolved: VClient017=%p VClientEntityList003=%p " "VEngineClient014=%p", g_iface.client, g_iface.entitylist, g_iface.engine); @@ -1159,10 +1255,10 @@ static void *setup_thread(void *) { g_iface.cvar = cf(cvar_names[i], nullptr); } if (!g_iface.cvar) { - size_t slash = g_engine.path.find_last_of('/'); + size_t slash = mod_engine().path.find_last_of('/'); if (slash != std::string::npos) { std::string path = - g_engine.path.substr(0, slash + 1) + "libvstdlib.so"; + mod_engine().path.substr(0, slash + 1) + "libvstdlib.so"; void *h = dlopen(path.c_str(), RTLD_NOLOAD | RTLD_LAZY | RTLD_GLOBAL); if (!h) h = dlopen(path.c_str(), RTLD_LAZY | RTLD_GLOBAL); @@ -1195,15 +1291,23 @@ static void *setup_thread(void *) { toastf(TOAST_WARN, "Debug overlay missing; ESP projection uses fallback"); if (!g_iface.enginetrace) toastf(TOAST_WARN, "Engine trace missing; vischeck fails open"); + init_set_stage(INIT_INTERFACES); + + init_conmsg(); + // Config load and ISurface/font discovery must finish before any hook is + // allowed past the init_live() gate: the game thread keeps calling + // CreateMove/PaintTraverse while this thread runs. + install_menu(); + init_set_stage(INIT_READY); if (!find_input_global()) { + init_set_stage(INIT_FAILED); toastf(TOAST_ERROR, "Input hook failed; movement features disabled"); return nullptr; } - - init_conmsg(); install_hook(); - install_menu(); + init_set_stage(INIT_LIVE); + logf("init: live (surface %p)", (void *)g_surface); if (g_surface) toastf(TOAST_INFO, "hl2hake loaded - Insert opens the menu"); return nullptr; diff --git a/src/hl2h.h b/src/hl2h.h index 86573d8..f1a1ea0 100644 --- a/src/hl2h.h +++ b/src/hl2h.h @@ -11,6 +11,8 @@ #include #include +#include "overlay.h" + // Shared float-precision constant. More digits than a float can hold are // rounded at compile time and have no runtime cost; a coarse value would only // hurt angular accuracy, never performance. @@ -23,6 +25,31 @@ constexpr float PI_F = 3.14159265f; void logf(const char *fmt, ...); void logd(const char *fmt, ...); // debug-only (HL2H_DEBUG=1) +// --------------------------------------------------------------------------- +// init readiness +// --------------------------------------------------------------------------- +// +// setup_thread runs detached while the game threads are already live, and some +// hooks are installed lazily from the game thread. Hooks therefore always stay +// installed but pass straight through to the original until setup reaches +// INIT_LIVE, so the game can never observe half-initialised state. INIT_FAILED +// is terminal: the cheat stays inert instead of half-working. +enum { + INIT_FAILED = -1, + INIT_NONE = 0, + INIT_INTERFACES = 1, // modules + interfaces + netvars resolved + INIT_READY = 2, // config loaded, ISurface/fonts resolved + INIT_LIVE = 3 // hooks may run their bodies +}; +void init_set_stage(int stage); +int init_stage(); // acquire load +inline bool init_live() { return init_stage() == INIT_LIVE; } + +// Clone a vtable into a new array, preserving the Itanium ABI header at +// [-2]/[-1] (offset-to-top and typeinfo) so RTTI on the hooked object still +// works. Returns the slot-0 pointer to install (i.e. the clone + 2). +void **vtable_clone(void *iface, size_t slots); + // --------------------------------------------------------------------------- // on-screen toasts // --------------------------------------------------------------------------- @@ -51,7 +78,17 @@ struct Module { return false; } }; -extern Module g_client, g_engine, g_server; +// Immutable module snapshots. A reader gets a reference that stays valid for +// the whole process: find_modules() publishes a new snapshot instead of +// mutating the old one, and retired snapshots are intentionally never freed, so +// a concurrent refresh can never invalidate a reference already in use. +const Module &mod_client(); +const Module &mod_engine(); +const Module &mod_server(); + +// True only when the whole byte range [p, p+n) lies inside one known module +// range. Use before dereferencing pointers read out of engine memory. +bool module_range(uintptr_t p, size_t n); // --------------------------------------------------------------------------- // game interfaces @@ -308,6 +345,9 @@ void skybox_invalidate(); void skybox_reload_image(); bool resolve_server_netvars(); int server_entity_health(int idx, int *maxhealth, int *lifestate, void **base_out); +// Authoritative server velocity; client-side m_vecVelocity is not networked for +// NPCs. Returns false when the server interface or datamap field is missing. +bool server_entity_velocity(int idx, Vector *out); bool server_set_velocity_z(int idx, float vz); int server_active_weapon(int idx); int server_npc_relation(int idx, int local_idx); @@ -332,7 +372,8 @@ void find_modules(); bool module_jump_slot(const char *path, const char *name, uintptr_t *vaddr, uintptr_t *bias); void init_conmsg(); -bool feature_active(int feature); +bool feature_active(int feature); // enabled AND bind held (or unbound) +bool feature_enabled(int feature); // enabled, regardless of bind bool feature_has_bind(int feature); bool feature_bind_held(int feature); bool esp_option(int opt); @@ -359,6 +400,8 @@ int engine_get_local_player(); void draw_text_col(int x, int y, const char *s, int r, int g, int b); void draw_text_small_col(int x, int y, const char *s, int r, int g, int b); void esp_draw(); +void esp_draw_fov_circle(); // aimbot cone, independent of the ESP toggle +void esp_reset_session(); // map change / respawn: clear per-map latches void esp_draw_items(); void esp_draw_projectiles(); void prop_tint_update(); @@ -395,7 +438,4 @@ bool gui_bind_capture_active(); QAngle gui_frozen_view(); void gui_config_init(); void gui_config_write(FILE *f); -// ISurface pointer (defined in menu.cpp, used for drawing tests) -struct ISurface; -extern ISurface *g_surface; #endif diff --git a/src/menu.cpp b/src/menu.cpp index 1064365..f3a9bb9 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -19,10 +19,10 @@ void init_conmsg() { if (g_conmsg) return; // fallback: dlopen libtier0.so from the engine directory - size_t slash = g_engine.path.find_last_of('/'); + size_t slash = mod_engine().path.find_last_of('/'); if (slash == std::string::npos) return; - std::string tier0 = g_engine.path.substr(0, slash + 1) + "libtier0.so"; + std::string tier0 = mod_engine().path.substr(0, slash + 1) + "libtier0.so"; void *h = dlopen(tier0.c_str(), RTLD_NOLOAD | RTLD_LAZY | RTLD_GLOBAL); if (!h) h = dlopen(tier0.c_str(), RTLD_LAZY | RTLD_GLOBAL); @@ -640,6 +640,11 @@ static bool config_load() { g_esp_col_friendly[key[1] == 'r' ? 0 : key[1] == 'g' ? 1 : 2] = v; } } + // Scalar-only sections ([view], [movement], [chams_*]) intentionally + // leave cur == nullptr. A stray active=/bind= line in one of them must + // not be dereferenced (hand-edited or corrupt config). + if (!cur) + continue; if (strcmp(key, "active") == 0) cur->active = atoi(val) != 0; else if (strcmp(key, "bind") == 0) @@ -867,6 +872,11 @@ bool feature_active(int feature) { return f->bind == 0 || key_held(f->bind); } +bool feature_enabled(int feature) { + FeatureState *f = feature_state(feature); + return f && f->active; +} + bool feature_has_bind(int feature) { FeatureState *f = feature_state(feature); return f && f->bind != 0; @@ -924,81 +934,39 @@ bool aimbot_fov_circle() { return g_aim_fov_circle; } struct ISurface; using HFont = unsigned long; -ISurface *g_surface = nullptr; static HFont g_font = 0; static HFont g_font_small = 0; -// VGUI_Surface030 vtable slots (confirmed via binary disassembly of vguimatsurface.so): -// 8 = PushMakeCurrent(VPANEL, bool useInsets) — reads args at 0xc/0x10(%ebp) -// 9 = PopMakeCurrent(VPANEL) — pops the make-current stack at this+0x264/0x270 -// 10 = DrawSetColor(int r, int g, int b, int a) — stores rgba at this+0x24 -// 12 = DrawFilledRect(int x0, int y0, int x1, int y1) — alpha check at this+0x27 -// 17 = DrawSetTextFont(HFont), 18 = DrawSetTextColor(int r,g,b,a) -// 20 = DrawSetTextPos(int x, int y), 22 = DrawPrintText(wchar_t*, len, drawType) -// 66 = CreateFont(), 67 = AddGlyphSetToFont(font, name, tall, weight, -// blur, scanlines, flags, rangeMin, rangeMax) -static constexpr int SURF_GetEmbeddedPanel = 6; -static constexpr int SURF_PushMakeCurrent = 8; -static constexpr int SURF_PopMakeCurrent = 9; -static constexpr int SURF_DrawSetColor = 10; -static constexpr int SURF_DrawFilledRect = 12; -static constexpr int SURF_DrawSetTextFont = 17; -static constexpr int SURF_DrawSetTextColor = 18; -static constexpr int SURF_DrawSetTextPos = 20; -static constexpr int SURF_DrawPrintText = 22; -static constexpr int SURF_CreateFont = 66; -static constexpr int SURF_AddGlyphSetToFont = 67; - -static int surf_get_embedded_panel() { - using Fn = int (*)(void *); - return vfunc(g_surface, SURF_GetEmbeddedPanel)(g_surface); -} +// ISurface access lives in overlay.cpp; these wrappers keep the call sites in +// this file unchanged. +static int surf_get_embedded_panel() { return overlay_embedded_panel(); } static void surf_push_make_current(int panel, bool use_insets) { - using Fn = void (*)(void *, int, bool); - vfunc(g_surface, SURF_PushMakeCurrent)(g_surface, panel, use_insets); + overlay_push_make_current(panel, use_insets); } static void surf_pop_make_current(int panel) { - using Fn = void (*)(void *, int); - vfunc(g_surface, SURF_PopMakeCurrent)(g_surface, panel); + overlay_pop_make_current(panel); } static void surf_draw_set_color(int r, int g, int b, int a) { - using Fn = void (*)(void *, int, int, int, int); - vfunc(g_surface, SURF_DrawSetColor)(g_surface, r, g, b, a); + overlay_color(r, g, b, a); } static void surf_draw_filled_rect(int x0, int y0, int x1, int y1) { - using Fn = void (*)(void *, int, int, int, int); - vfunc(g_surface, SURF_DrawFilledRect)(g_surface, x0, y0, x1, y1); + overlay_fill(x0, y0, x1, y1); } static void surf_draw_set_text_color(int r, int g, int b, int a) { - using Fn = void (*)(void *, int, int, int, int); - vfunc(g_surface, SURF_DrawSetTextColor)(g_surface, r, g, b, a); -} -static void surf_draw_set_text_font(HFont f) { - using Fn = void (*)(void *, HFont); - vfunc(g_surface, SURF_DrawSetTextFont)(g_surface, f); -} -static void surf_draw_set_text_pos(int x, int y) { - using Fn = void (*)(void *, int, int); - vfunc(g_surface, SURF_DrawSetTextPos)(g_surface, x, y); + overlay_text_color(r, g, b, a); } +static void surf_draw_set_text_font(HFont f) { overlay_text_font(f); } +static void surf_draw_set_text_pos(int x, int y) { overlay_text_pos(x, y); } static void surf_draw_print_text(const wchar_t *text, int count) { - using Fn = void (*)(void *, const wchar_t *, int, int); - vfunc(g_surface, SURF_DrawPrintText)(g_surface, text, count, 0); // 0 = FONT_DRAW_DEFAULT -} -static HFont surf_create_font() { - using Fn = HFont (*)(void *); - return vfunc(g_surface, SURF_CreateFont)(g_surface); + overlay_print_text(text, count); } +static HFont surf_create_font() { return overlay_create_font(); } static bool surf_set_font_glyph_set(HFont f, const char *name, int tall, int weight, int blur, int scanlines, int flags, int range_min = 0, int range_max = 0) { - using Fn = bool (*)(void *, HFont, const char *, int, int, int, int, int, int, - int); - return vfunc(g_surface, SURF_AddGlyphSetToFont)(g_surface, f, name, tall, - weight, blur, scanlines, - flags, range_min, - range_max); + return overlay_add_glyph_set(f, name, tall, weight, blur, scanlines, flags, + range_min, range_max); } // wide string conversion for DrawPrintText @@ -1071,6 +1039,11 @@ void fullbright_update() { // (see hkdDrawPrintText hook in hl2h.cpp): we wrap our draws in // PushMakeCurrent/PopMakeCurrent so they are submitted with a clean clip. void outer_draw() { + // PaintTraverseEx runs for every VGUI panel and DrawPrintText for every text + // draw, so collapse them to one draw per engine frame (see overlay.h). + if (!overlay_should_draw()) + return; + poll_keyboard(); fullbright_update(); world_tint_update(); @@ -1083,10 +1056,16 @@ void outer_draw() { bool esp_on = feature_active(FEAT_ESP); bool items_on = feature_active(FEAT_ITEM_ESP); bool projs_on = feature_active(FEAT_PROJECTILES); + // The FOV circle belongs to the aim features, not to ESP, and it is a visual + // aid: use the enable toggle rather than the bind, otherwise it only appears + // while the aim key is held (default bind is mouse5). + bool aim_on = (feature_enabled(FEAT_AIMBOT) || + feature_enabled(FEAT_SILENTAIM)) && + aimbot_fov_circle(); bool toasts = gui_toasts_active(); if (!g_surface || (!gui_is_open() && !draw_test && !esp_on && !items_on && !projs_on && - !toasts)) + !aim_on && !toasts)) return; // Level/menu -> game transitions can invalidate the VGUI font handle. @@ -1147,6 +1126,8 @@ void outer_draw() { if (!panel) return; surf_push_make_current(panel, false); + if (aim_on && overlay_font) + esp_draw_fov_circle(); if (esp_on && overlay_font) esp_draw(); if (items_on && overlay_font) diff --git a/src/modules.cpp b/src/modules.cpp index 7a82028..a671d82 100644 --- a/src/modules.cpp +++ b/src/modules.cpp @@ -1,10 +1,27 @@ // modules.cpp — /proc/self/maps parsing and module discovery #include "hl2h.h" +#include #include -#include #include -static std::mutex g_modules_mutex; +// Immutable snapshots. Readers load a pointer with acquire semantics and get a +// module that never changes underneath them. find_modules() publishes a fresh +// copy instead of mutating the current one; retired snapshots are deliberately +// leaked so a reference already handed to another thread stays valid. +static const Module g_no_module; +static std::atomic g_client_snap{&g_no_module}; +static std::atomic g_engine_snap{&g_no_module}; +static std::atomic g_server_snap{&g_no_module}; + +const Module &mod_client() { + return *g_client_snap.load(std::memory_order_acquire); +} +const Module &mod_engine() { + return *g_engine_snap.load(std::memory_order_acquire); +} +const Module &mod_server() { + return *g_server_snap.load(std::memory_order_acquire); +} static bool basename_is(const char *path, const char *base) { size_t pl = strlen(path), bl = strlen(base); @@ -55,6 +72,9 @@ void find_modules() { else if (engine.valid() && engine.ranges.back().file && lo == engine.ranges.back().hi && hi - lo <= 0x1000000) m = &engine; + else if (server.valid() && server.ranges.back().file && + lo == server.ranges.back().hi && hi - lo <= 0x1000000) + m = &server; } if (!m) continue; @@ -72,16 +92,25 @@ void find_modules() { } } fclose(f); - // setup_thread and resolve_server_netvars can both call this; the old code - // reset the shared Module globals and reallocated their vectors live, which - // crashed under the startup thread. Publish complete snapshots instead. - std::lock_guard lock(g_modules_mutex); + // setup_thread and resolve_server_netvars can both call this. Publish whole + // snapshots atomically so a reader never iterates a vector being reallocated. if (client.valid()) - g_client = std::move(client); + g_client_snap.store(new Module(std::move(client)), std::memory_order_release); if (engine.valid()) - g_engine = std::move(engine); + g_engine_snap.store(new Module(std::move(engine)), std::memory_order_release); if (server.valid()) - g_server = std::move(server); + g_server_snap.store(new Module(std::move(server)), std::memory_order_release); +} + +bool module_range(uintptr_t p, size_t n) { + if (n == 0) + return true; + uintptr_t last = p + (n - 1); + if (last < p) + return false; // address-space wrap + return (mod_client().contains(p) && mod_client().contains(last)) || + (mod_engine().contains(p) && mod_engine().contains(last)) || + (mod_server().contains(p) && mod_server().contains(last)); } // --------------------------------------------------------------------------- diff --git a/src/netvars.cpp b/src/netvars.cpp index e22ff38..8e9579c 100644 --- a/src/netvars.cpp +++ b/src/netvars.cpp @@ -6,11 +6,15 @@ // ClientClass (2004-era): CreateFn@0, CreateEventFn@4, Name@8, RecvTable@0xC, Next@0x10 static bool module_string(uintptr_t p, char *out, size_t outn) { - if (!g_client.contains(p) && !g_engine.contains(p) && !g_server.contains(p)) + if (!p || outn == 0 || !module_range(p, 1)) return false; const char *s = (const char *)p; size_t i = 0; while (i < outn - 1) { + // Validate every byte, not just the first: a string near the end of a + // mapping must not walk into an unmapped page looking for its NUL. + if (!module_range(p + i, 1)) + return false; unsigned char ch = (unsigned char)s[i]; if (ch == 0) break; @@ -30,9 +34,13 @@ static int g_dumped_tables = 0; static void collect_netvars(uintptr_t table, uintptr_t base_off, int depth) { if (depth > 8) return; + // RecvTable is 16 bytes; validate before the first read (the table pointer + // and the nested pDataTable both come from engine memory). + if (!module_range(table, 0x10)) + return; uintptr_t props = *(uintptr_t *)(table + 0x00); int32_t nprops = *(int32_t *)(table + 0x04); - if (!props || !g_client.contains(props) || nprops <= 0 || nprops > 512) + if (!props || !mod_client().contains(props) || nprops <= 0 || nprops > 512) return; char tname[64]; @@ -52,7 +60,7 @@ static void collect_netvars(uintptr_t table, uintptr_t base_off, int depth) { for (int32_t i = 0; i < nprops; i++) { uintptr_t prop = props + (size_t)i * 0x3C; - if (!g_client.contains(prop)) + if (!mod_client().contains(prop)) return; uintptr_t pname = *(uintptr_t *)(prop + 0x00); int32_t ptype = *(int32_t *)(prop + 0x04); @@ -74,7 +82,7 @@ static void collect_netvars(uintptr_t table, uintptr_t base_off, int depth) { strncmp(pname2, "m_hMyWeapons", 12) == 0) { int off = (int)(base_off + (uint32_t)poff); uintptr_t elem = parray ? parray : ptable; - if (elem) { + if (elem && module_range(elem, 0x30)) { int32_t eoff = *(int32_t *)(elem + 0x2C); if (eoff) off = (int)(base_off + (uint32_t)eoff); @@ -89,7 +97,7 @@ static void collect_netvars(uintptr_t table, uintptr_t base_off, int depth) { if (g_net.ammo < 0 && strncmp(pname2, "m_iAmmo", 7) == 0) { int off = (int)(base_off + (uint32_t)poff); uintptr_t elem = parray ? parray : ptable; - if (elem) { + if (elem && module_range(elem, 0x30)) { int32_t eoff = *(int32_t *)(elem + 0x2C); if (eoff) off = (int)(base_off + (uint32_t)eoff); @@ -113,7 +121,7 @@ static void collect_netvars(uintptr_t table, uintptr_t base_off, int depth) { } else if (strcmp(pname2, "m_fFlags") == 0 && g_net.flags < 0) { g_net.flags = (int)(base_off + (uint32_t)poff); } else if (strcmp(pname2, "m_vecVelocity") == 0 && g_net.velocity < 0) { - if (parray && g_client.contains(parray)) { + if (parray && module_range(parray, 0x30)) { int32_t eoff = *(int32_t *)(parray + 0x2C); g_net.velocity = (int)(base_off + (uint32_t)(eoff ? eoff : poff)); } else { @@ -126,6 +134,16 @@ static void collect_netvars(uintptr_t table, uintptr_t base_off, int depth) { } else if (strcmp(pname2, "m_hActiveWeapon") == 0 && g_net.activeweapon < 0) { g_net.activeweapon = (int)(base_off + (uint32_t)poff); + } else if (strcmp(pname2, "m_hOwnerEntity") == 0 && g_net.owner < 0) { + // Used by vischeck to ignore a target's held weapon / bone followers, + // and by item ESP for weapon ownership. + int off = (int)(base_off + (uint32_t)poff); + if (off >= 0x20 && off <= 0x2000) + g_net.owner = off; + } else if (strcmp(pname2, "m_hOwner") == 0 && g_net.weapon_owner < 0) { + int off = (int)(base_off + (uint32_t)poff); + if (off >= 0x20 && off <= 0x2000) + g_net.weapon_owner = off; } else if (strcmp(pname2, "m_clrRender") == 0 && g_net.clr_render < 0) { int off = (int)(base_off + (uint32_t)poff); // Reject the 0 offset some embedded tables report; the real render @@ -149,7 +167,7 @@ static void collect_netvars(uintptr_t table, uintptr_t base_off, int depth) { } else if (strcmp(pname2, "m_iTeamNum") == 0 && g_net.teamnum < 0) { g_net.teamnum = (int)(base_off + (uint32_t)poff); } else if (strcmp(pname2, "m_vecOrigin") == 0 && g_net.origin < 0) { - if (parray && g_client.contains(parray)) { + if (parray && module_range(parray, 0x30)) { int32_t eoff = *(int32_t *)(parray + 0x2C); g_net.origin = (int)(base_off + (uint32_t)(eoff ? eoff : poff)); } else { @@ -157,21 +175,21 @@ static void collect_netvars(uintptr_t table, uintptr_t base_off, int depth) { } } else if (strncmp(pname2, "m_vecViewOffset", 15) == 0 && g_net.viewoffset < 0) { - if (parray && g_client.contains(parray)) { + if (parray && module_range(parray, 0x30)) { int32_t eoff = *(int32_t *)(parray + 0x2C); g_net.viewoffset = (int)(base_off + (uint32_t)(eoff ? eoff : poff)); } else { g_net.viewoffset = (int)(base_off + (uint32_t)poff); } } else if (strcmp(pname2, "m_vecMins") == 0 && g_net.mins < 0) { - if (parray && g_client.contains(parray)) { + if (parray && module_range(parray, 0x30)) { int32_t eoff = *(int32_t *)(parray + 0x2C); g_net.mins = (int)(base_off + (uint32_t)(eoff ? eoff : poff)); } else { g_net.mins = (int)(base_off + (uint32_t)poff); } } else if (strcmp(pname2, "m_vecMaxs") == 0 && g_net.maxs < 0) { - if (parray && g_client.contains(parray)) { + if (parray && module_range(parray, 0x30)) { int32_t eoff = *(int32_t *)(parray + 0x2C); g_net.maxs = (int)(base_off + (uint32_t)(eoff ? eoff : poff)); } else { @@ -187,7 +205,7 @@ static bool resolve_movetype() { const size_t nlen = sizeof(needle); uintptr_t str_va = 0; - for (auto &r : g_client.ranges) { + for (auto &r : mod_client().ranges) { const uint8_t *base = (const uint8_t *)r.lo; size_t span = r.hi - r.lo; for (size_t i = 0; i + nlen <= span; i++) { @@ -208,7 +226,7 @@ static bool resolve_movetype() { int counts[16] = {}; int ncand = 0; const uint32_t target = (uint32_t)str_va; - for (auto &r : g_client.ranges) { + for (auto &r : mod_client().ranges) { const uint8_t *base = (const uint8_t *)r.lo; size_t span = r.hi - r.lo; for (size_t off = 4; off + 8 <= span; off += 4) { @@ -247,7 +265,7 @@ static bool resolve_absvelocity() { const size_t nlen = sizeof(needle); uintptr_t str_va = 0; - for (auto &r : g_client.ranges) { + for (auto &r : mod_client().ranges) { const uint8_t *base = (const uint8_t *)r.lo; size_t span = r.hi - r.lo; for (size_t i = 0; i + nlen <= span; i++) { @@ -268,7 +286,7 @@ static bool resolve_absvelocity() { int counts[16] = {}; int ncand = 0; const uint32_t target = (uint32_t)str_va; - for (auto &r : g_client.ranges) { + for (auto &r : mod_client().ranges) { const uint8_t *base = (const uint8_t *)r.lo; size_t span = r.hi - r.lo; for (size_t off = 4; off + 8 <= span; off += 4) { @@ -306,12 +324,12 @@ void resolve_netvars() { using GetAllClassesFn = void *(*)(void *); uintptr_t head = (uintptr_t)vfunc(g_iface.client, 8)(g_iface.client); - if (!g_client.contains(head)) { + if (!mod_client().contains(head)) { logf("GetAllClasses returned %p, outside client module", (void *)head); return; } int classes = 0; - for (uintptr_t cc = head; cc && g_client.contains(cc) && classes < 1024; + for (uintptr_t cc = head; cc && mod_client().contains(cc) && classes < 1024; cc = *(uintptr_t *)(cc + 0x10)) { classes++; collect_netvars(*(uintptr_t *)(cc + 0x0C), 0, 0); diff --git a/src/overlay.cpp b/src/overlay.cpp new file mode 100644 index 0000000..2445d95 --- /dev/null +++ b/src/overlay.cpp @@ -0,0 +1,140 @@ +// overlay.cpp — the single ISurface access layer. +#include "hl2h.h" +#include + +ISurface *g_surface = nullptr; + +// --------------------------------------------------------------------------- +// per-frame draw budget +// --------------------------------------------------------------------------- + +static bool g_drawn_this_frame = false; +static double g_frame_mark_at = 0.0; + +static double overlay_now() { + timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9; +} + +void overlay_frame_mark() { + g_drawn_this_frame = false; + g_frame_mark_at = overlay_now(); +} + +bool overlay_should_draw() { + if (g_drawn_this_frame && overlay_now() - g_frame_mark_at < 0.10) + return false; + g_drawn_this_frame = true; + return true; +} + +void overlay_color(int r, int g, int b, int a) { + if (!g_surface) + return; + using Fn = void (*)(void *, int, int, int, int); + vfunc(g_surface, SURF_DrawSetColor)(g_surface, r, g, b, a); +} + +void overlay_fill(int x0, int y0, int x1, int y1) { + if (!g_surface) + return; + using Fn = void (*)(void *, int, int, int, int); + vfunc(g_surface, SURF_DrawFilledRect)(g_surface, x0, y0, x1, y1); +} + +void overlay_outline_rect(int x0, int y0, int x1, int y1) { + if (!g_surface) + return; + using Fn = void (*)(void *, int, int, int, int); + vfunc(g_surface, SURF_DrawOutlinedRect)(g_surface, x0, y0, x1, y1); +} + +void overlay_line(int x0, int y0, int x1, int y1) { + if (!g_surface) + return; + using Fn = void (*)(void *, int, int, int, int); + vfunc(g_surface, SURF_DrawLine)(g_surface, x0, y0, x1, y1); +} + +void overlay_text_font(HFont f) { + if (!g_surface) + return; + using Fn = void (*)(void *, HFont); + vfunc(g_surface, SURF_DrawSetTextFont)(g_surface, f); +} + +void overlay_text_color(int r, int g, int b, int a) { + if (!g_surface) + return; + using Fn = void (*)(void *, int, int, int, int); + vfunc(g_surface, SURF_DrawSetTextColor)(g_surface, r, g, b, a); +} + +void overlay_text_pos(int x, int y) { + if (!g_surface) + return; + using Fn = void (*)(void *, int, int); + vfunc(g_surface, SURF_DrawSetTextPos)(g_surface, x, y); +} + +void overlay_print_text(const wchar_t *text, int count, int draw_type) { + if (!g_surface || !text || count <= 0) + return; + using Fn = void (*)(void *, const wchar_t *, int, int); + vfunc(g_surface, SURF_DrawPrintText)(g_surface, text, count, draw_type); +} + +HFont overlay_create_font() { + if (!g_surface) + return 0; + using Fn = HFont (*)(void *); + return vfunc(g_surface, SURF_CreateFont)(g_surface); +} + +bool overlay_add_glyph_set(HFont f, const char *name, int tall, int weight, + int blur, int scanlines, int flags, int range_min, + int range_max) { + if (!g_surface || !name) + return false; + using Fn = bool (*)(void *, HFont, const char *, int, int, int, int, int, int, + int); + return vfunc(g_surface, SURF_AddGlyphSetToFont)( + g_surface, f, name, tall, weight, blur, scanlines, flags, range_min, + range_max); +} + +void overlay_screen_size(int &w, int &h) { + if (!g_surface) + return; + using Fn = void (*)(void *, int &, int &); + vfunc(g_surface, SURF_GetScreenSize)(g_surface, w, h); +} + +void overlay_text_size(HFont f, const wchar_t *text, int &w, int &h) { + if (!g_surface || !text) + return; + using Fn = void (*)(void *, HFont, const wchar_t *, int &, int &); + vfunc(g_surface, SURF_GetTextSize)(g_surface, f, text, w, h); +} + +int overlay_embedded_panel() { + if (!g_surface) + return 0; + using Fn = int (*)(void *); + return vfunc(g_surface, SURF_GetEmbeddedPanel)(g_surface); +} + +void overlay_push_make_current(int panel, bool use_insets) { + if (!g_surface) + return; + using Fn = void (*)(void *, int, bool); + vfunc(g_surface, SURF_PushMakeCurrent)(g_surface, panel, use_insets); +} + +void overlay_pop_make_current(int panel) { + if (!g_surface) + return; + using Fn = void (*)(void *, int); + vfunc(g_surface, SURF_PopMakeCurrent)(g_surface, panel); +} diff --git a/src/overlay.h b/src/overlay.h new file mode 100644 index 0000000..6cf86c9 --- /dev/null +++ b/src/overlay.h @@ -0,0 +1,62 @@ +#ifndef HL2H_OVERLAY_H +#define HL2H_OVERLAY_H + +#include + +// VGUI_Surface030 vtable slots, confirmed against the shipped +// vguimatsurface.so. This header is the only place that knows them; every +// consumer goes through the functions below. +enum SurfaceSlot { + SURF_GetEmbeddedPanel = 6, + SURF_PushMakeCurrent = 8, + SURF_PopMakeCurrent = 9, + SURF_DrawSetColor = 10, + SURF_DrawFilledRect = 12, + SURF_DrawOutlinedRect = 14, + SURF_DrawLine = 15, + SURF_DrawSetTextFont = 17, + SURF_DrawSetTextColor = 18, + SURF_DrawSetTextPos = 20, + SURF_DrawPrintText = 22, + SURF_GetScreenSize = 38, + SURF_CreateFont = 66, + SURF_AddGlyphSetToFont = 67, + SURF_GetTextSize = 75 +}; + +using HFont = unsigned long; + +struct ISurface; + +// Set once by install_menu(); every wrapper below is a no-op while it is null. +extern ISurface *g_surface; + +void overlay_color(int r, int g, int b, int a); +void overlay_fill(int x0, int y0, int x1, int y1); +void overlay_outline_rect(int x0, int y0, int x1, int y1); +void overlay_line(int x0, int y0, int x1, int y1); +void overlay_text_font(HFont f); +void overlay_text_color(int r, int g, int b, int a); +void overlay_text_pos(int x, int y); +void overlay_print_text(const wchar_t *text, int count, int draw_type = 0); +HFont overlay_create_font(); +bool overlay_add_glyph_set(HFont f, const char *name, int tall, int weight, + int blur, int scanlines, int flags, int range_min, + int range_max); +void overlay_screen_size(int &w, int &h); +void overlay_text_size(HFont f, const wchar_t *text, int &w, int &h); +int overlay_embedded_panel(); +void overlay_push_make_current(int panel, bool use_insets); +void overlay_pop_make_current(int panel); + +// Per-frame draw budget. The engine calls PaintTraverseEx for every panel, and +// DrawPrintText for every text draw, so the overlay must be collapsed to one +// draw per frame. A timer cannot do that: panels spaced a few ms apart in the +// same frame would draw twice and double the alpha of translucent elements. +// overlay_frame_mark() is called from FRAME_RENDER_START, which is the real +// frame boundary; if that stops arriving (menu/pause) a 10 ms fallback keeps +// the GUI repainting. +void overlay_frame_mark(); +bool overlay_should_draw(); + +#endif diff --git a/src/server.cpp b/src/server.cpp index b1661ac..0d14c59 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -68,7 +68,7 @@ static bool g_srv_checked = false; static uintptr_t find_string(const char *s) { size_t n = strlen(s) + 1; - for (auto &r : g_server.ranges) { + for (auto &r : mod_server().ranges) { const uint8_t *base = (const uint8_t *)r.lo; size_t span = r.hi - r.lo; for (size_t i = 0; i + n <= span; i++) @@ -92,7 +92,7 @@ static int datamap_offsets(const char *name, int want_type, int min_off, if (!str) return 0; int n = 0; - for (auto &r : g_server.ranges) { + for (auto &r : mod_server().ranges) { const uint8_t *base = (const uint8_t *)r.lo; size_t span = r.hi - r.lo; for (size_t off = 4; off + 0x18 <= span; off += 4) { @@ -111,7 +111,7 @@ static int datamap_offsets(const char *name, int want_type, int min_off, continue; // DEFINE_KEYFIELD entries have an external name ("health"); plain // DEFINE_FIELD entries have none. Either way it must be in server.so. - if (ext && !g_server.contains(ext)) + if (ext && !mod_server().contains(ext)) continue; bool dup = false; for (int i = 0; i < n; i++) @@ -144,9 +144,9 @@ bool resolve_server_netvars() { if ((retry++ % 100) != 0) return false; // server.so is only mapped once a map is running; refresh the module list. - if (!g_server.valid()) + if (!mod_server().valid()) find_modules(); - if (!g_server.valid()) { + if (!mod_server().valid()) { if (!fail_logged) { fail_logged = true; logd("server datamap: server.so not mapped yet"); @@ -209,7 +209,7 @@ bool resolve_server_netvars() { "followtarget=%d spawnflags=%d hacked=%d npcstate=%d " "takedamage=%d mapname=%d armor=%d detonate=%d dmgradius=%d " "ammo=%d myweapons=%d juice_suit=%d juice_health=%d", - g_server.path.c_str(), g_srv_health, g_srv_maxhealth, g_srv_lifestate, + mod_server().path.c_str(), g_srv_health, g_srv_maxhealth, g_srv_lifestate, g_srv_modelindex, g_srv_absvelocity, g_srv_velocity, g_srv_activeweapon, g_srv_relationship, g_srv_followtarget, g_srv_spawnflags, g_srv_hacked, g_srv_npcstate, g_srv_takedamage, @@ -219,7 +219,7 @@ bool resolve_server_netvars() { } else if (!fail_logged) { fail_logged = true; logf("server datamap: scan failed (server.so at %s)", - g_server.path.c_str()); + mod_server().path.c_str()); toastf(TOAST_WARN, "Server datamap scan failed; jumpbug assist limited"); } return g_srv_ready; @@ -275,6 +275,19 @@ int server_entity_health(int idx, int *maxhealth, int *lifestate, return hp; } +bool server_entity_velocity(int idx, Vector *out) { + if (!out || g_srv_velocity <= 0) + return false; + void *base = nullptr; + if (server_entity_health(idx, nullptr, nullptr, &base) < 0 || !base) + return false; + uintptr_t b = (uintptr_t)base; + out->x = *(float *)(b + g_srv_velocity); + out->y = *(float *)(b + g_srv_velocity + 4); + out->z = *(float *)(b + g_srv_velocity + 8); + return true; +} + int server_model_index(void *base) { if (!base || g_srv_modelindex <= 0) return -1; diff --git a/src/studio.cpp b/src/studio.cpp index 3ee6cc5..69a2e07 100644 --- a/src/studio.cpp +++ b/src/studio.cpp @@ -68,9 +68,11 @@ const char *info_model_name(void *info) { void *hdr = *(void **)info; if (!hdr) return nullptr; + // studiohdr_t::name is char[64]; scanning further would read the header's + // length/eyeposition fields and could return a bogus "name". const char *name = (const char *)hdr + 12; - size_t n = strnlen(name, 128); - if (n < 7 || n >= 128) + size_t n = strnlen(name, 64); + if (n < 7 || n >= 64) return nullptr; for (size_t i = 0; i < n; i++) { unsigned char c = (unsigned char)name[i]; @@ -127,8 +129,13 @@ void hook_draw_model(void *self, void *results, void *info, void *bone, } g_orig_draw_model(self, results, info, bone, flex, flex_delayed, model_origin, flags); - if (prop) + if (prop) { g_prop_depth--; + // Don't leave the prop tint bound: models drawn without an explicit + // SetColorModulation would inherit it. + const float white[4] = {1.0f, 1.0f, 1.0f, 1.0f}; + g_orig_set_color(self, white); + } } void hook_draw_array(void *self, void *info, int count, void *instances, @@ -158,8 +165,11 @@ void hook_draw_static_prop(void *self, void *info, void *model_to_world, if (prop) g_prop_depth++; g_orig_draw_static(self, info, model_to_world, flags); - if (prop) + if (prop) { g_prop_depth--; + const float white[4] = {1.0f, 1.0f, 1.0f, 1.0f}; + g_orig_set_color(self, white); + } } std::string loaded_module_path(const char *needle) { @@ -279,7 +289,8 @@ void chams_apply(void *mat, bool ignorez, int style, bool force_white, vfunc(mat, 29)(mat, 1 << 6 /* SELFILLUM */, style == 5); } -void chams_reset(void *mat) { using FlagFn = void (*)(void *, int, bool); +void chams_reset(void *mat) { + using FlagFn = void (*)(void *, int, bool); vfunc(mat, 29)(mat, 1 << 15, false); vfunc(mat, 29)(mat, 1 << 10, false); vfunc(mat, 29)(mat, 1 << 28, false); @@ -288,6 +299,11 @@ void chams_reset(void *mat) { using FlagFn = void (*)(void *, int, bool); vfunc(mat, 29)(mat, 1 << 6, false); chams_color(mat, 1.0f, 1.0f, 1.0f); chams_alpha(mat, 1.0f); + // AlphaModulate is applied per-entity, separate from $alpha, and was left at + // the last chams alpha. Styles 0/3/4/6 share one material, so a translucent + // pass permanently half-faded it for every later draw. + using AlphaFn = void (*)(void *, float); + vfunc(mat, 27)(mat, 1.0f); } // Model draws take their final alpha from IVRenderView::SetBlend; material @@ -310,6 +326,10 @@ void hkdModelExecute(void *self, const void *state, const void *info, void *bone) { if (!g_orig_model_exec) return; + if (!init_live()) { + g_orig_model_exec(self, state, info, bone); + return; + } if (feature_active(FEAT_CHAMS) && info && g_force_override) { // ModelRenderInfo_t::entity_index is at 0x30 on 32-bit. int idx = *(const int *)((const char *)info + 0x30); @@ -497,17 +517,32 @@ void chams_preload() { if (now - last < 1.5) return; last = now; + using RefFn = void (*)(void *); for (int i = 0; i < 7; i++) { if (g_style_mats[i]) continue; - void *m = world_find_material(chams_material_name(i)); + const char *name = chams_material_name(i); + // Styles 0/3/4/6 and 2/5 resolve to the same material. Reuse the pointer + // and reference it only once instead of incrementing the shared material + // up to four times. + void *m = nullptr; + for (int j = 0; j < 7; j++) { + if (g_style_mats[j] && strcmp(chams_material_name(j), name) == 0) { + m = g_style_mats[j]; + break; + } + } + bool fresh = m == nullptr; + if (!m) + m = world_find_material(name); if (m) { // FindMaterial can hand back an unreferenced material; the engine warns // ("bad reference count 0") and may reset its vars when it is bound. - using RefFn = void (*)(void *); - vfunc(m, 12)(m); // IMaterial::IncrementReferenceCount + if (fresh) + vfunc(m, 12)(m); // IMaterial::IncrementReferenceCount g_style_mats[i] = m; - logf("chams: preloaded style %d %s = %p", i, chams_material_name(i), m); + logf("chams: preloaded style %d %s = %p%s", i, name, m, + fresh ? "" : " (shared)"); } } if (!g_style_mats[7]) { @@ -535,14 +570,17 @@ void chams_init() { } g_force_override = (ForceOverrideFn)vt[1]; g_orig_model_exec = (ModelExecFn)vt[19]; - if (!g_engine.contains((uintptr_t)g_force_override) || - !g_engine.contains((uintptr_t)g_orig_model_exec)) { + if (!mod_engine().contains((uintptr_t)g_force_override) || + !mod_engine().contains((uintptr_t)g_orig_model_exec)) { logf("chams: implausible vtable entries override=%p draw=%p", (void *)g_force_override, (void *)g_orig_model_exec); return; } - static void *nvt[32]; - memcpy(nvt, vt, sizeof(nvt)); + void **nvt = vtable_clone(g_iface.modelrender, 32); + if (!nvt) { + logf("chams: vtable clone failed"); + return; + } nvt[19] = (void *)&hkdModelExecute; g_modelrender = g_iface.modelrender; *(void ***)g_modelrender = nvt; @@ -581,8 +619,11 @@ void studio_props_init() { logf("studio: interface has no vtable"); return; } - static void *vt[49]; - memcpy(vt, old_vt, sizeof(vt)); + void **vt = vtable_clone(sr, 49); + if (!vt) { + logf("studio: vtable clone failed"); + return; + } g_orig_set_color = (SetColorFn)vt[27]; g_orig_set_alpha = (SetAlphaFn)vt[28]; diff --git a/src/vischeck.cpp b/src/vischeck.cpp index 6b531b9..6126c88 100644 --- a/src/vischeck.cpp +++ b/src/vischeck.cpp @@ -88,12 +88,29 @@ struct EntityFilter : ITraceFilterCompat { int GetTraceType() const override { return 0; } // TRACE_EVERYTHING }; +// True when pEntity is a child of target: held weapons are separate entities +// and heavy NPCs use phys_bone_follower entities for their collision model, so +// a trace toward an NPC's torso otherwise stops on the weapon/follower and the +// target reads as occluded. CBaseHandle stores the entity index in the low +// bits, so the owner handle resolves through the client entity list. +static bool owned_by(void *pEntity, void *target) { + if (!pEntity || !target || g_net.owner < 0) + return false; + uint32_t h = *(uint32_t *)((uintptr_t)pEntity + g_net.owner); + int idx = (int)(h & 0x1FFFu); + if (idx <= 0) + return false; + return entitylist_get(idx) == target; +} + struct VisibilityFilter : ITraceFilterCompat { void *self; void *target; VisibilityFilter(void *s, void *t) : self(s), target(t) {} bool ShouldHitEntity(void *pEntity, int) override { - return pEntity != self && pEntity != target; + if (pEntity == self || pEntity == target) + return false; + return !owned_by(pEntity, target); } int GetTraceType() const override { return 0; } // TRACE_EVERYTHING }; @@ -176,14 +193,14 @@ bool is_visible(const Vector &eye, const Vector &point, void *self, // Trace everything that bullets care about: world, static props, crates and // NPCs. Non-solid glass and glass whose material name marks it breakable is // skipped; solid non-break glass (bulletproof) blocks. + using TraceRayFn = void (*)(void *, const RayCompat *, unsigned, void *, + GameTraceCompat *); Vector start = eye; for (int pass = 0; pass < 5; pass++) { RayCompat ray; make_ray(ray, start, point); GameTraceCompat tr{}; VisibilityFilter filter(self, target); - using TraceRayFn = void (*)(void *, const RayCompat *, unsigned, void *, - GameTraceCompat *); vfunc(g_iface.enginetrace, 4)(g_iface.enginetrace, &ray, MASK_SHOT, &filter, &tr); if (tr.startsolid || tr.allsolid) @@ -196,11 +213,34 @@ bool is_visible(const Vector &eye, const Vector &point, void *self, bool solid = (tr.contents & CONTENTS_SOLID) != 0; bool breakable_glass = glass && surface_is_breakable_glass(tr.surface); if (glass && (!solid || breakable_glass)) { - // Step just past the pane and look for the next obstruction. - start = tr.endpos; - start.x += dir.x * 0.5f; - start.y += dir.y * 0.5f; - start.z += dir.z * 0.5f; + // Glass brushes are commonly 1-4 units thick, so the old fixed 0.5-unit + // step landed inside the brush and the next pass reported startsolid, + // which read as "blocked". Advance until a probe from the new start is + // clear, doubling the step, and stop if we pass the target. + Vector next = tr.endpos; + float step = 1.0f; + bool clear = false; + for (int guard = 0; guard < 16 && !clear; guard++) { + next.x += dir.x * step; + next.y += dir.y * step; + next.z += dir.z * step; + float ox = next.x - point.x, oy = next.y - point.y, + oz = next.z - point.z; + if (ox * ox + oy * oy + oz * oz < 0.25f) + return true; // stepped past the target; nothing left to block it + RayCompat probe; + make_ray(probe, next, point); + GameTraceCompat ptr{}; + VisibilityFilter pfilter(self, target); + vfunc(g_iface.enginetrace, 4)( + g_iface.enginetrace, &probe, MASK_SHOT, &pfilter, &ptr); + clear = !ptr.startsolid && !ptr.allsolid; + if (!clear) + step *= 2.0f; + } + if (!clear) + return false; // still inside geometry; treat as blocked + start = next; logd("vischeck: skipped glass surface=%s contents=0x%x solid=%d break=%d", tr.surface.name ? tr.surface.name : "?", tr.contents, (int)solid, (int)breakable_glass); @@ -270,7 +310,11 @@ bool point_ground(const Vector &point, void *ignore) { vfunc(g_iface.enginetrace, 4)(g_iface.enginetrace, &ray, MASK_PLAYERSOLID, &filter, &tr); // A start inside geometry means a wall/step there, not a ledge. - return tr.startsolid || tr.fraction < 1.0f; + if (tr.startsolid) + return true; + // Only a standable surface counts: a steep cliff face hit within 96 units is + // not a floor, and treating it as one suppressed the edge jump. + return tr.fraction < 1.0f && tr.normal.z >= 0.7f; } // Vertical distance from the standing player hull's feet to the first diff --git a/src/world.cpp b/src/world.cpp index ea186b4..a0576ab 100644 --- a/src/world.cpp +++ b/src/world.cpp @@ -633,6 +633,10 @@ bool load_png_rgba(const char *path, std::vector &pixels, } std::vector g_sky_pixels; +// Pristine copy of the decoded PNG. HDR compensation is applied to +// g_sky_pixels in place, so without this the linearised/darkened bytes bled +// into the next map when it used the LDR sky shader. +std::vector g_sky_pixels_src; int g_sky_px_w = 0, g_sky_px_h = 0; // ITextureRegenerator has two virtuals: RegenerateTextureBits then Release. @@ -717,6 +721,28 @@ bool is_sky_material(const char *name, const char *group) { strncmp(name, "skybox/", 7) == 0; } +// ITexture reference counting (material system header order, matching the +// slots already used here: SetTextureRegenerator = 12, Download = 13): +// 10 = IncrementReferenceCount, 11 = DecrementReferenceCount +// The engine owns the original textures; holding a reference keeps them valid +// between "capture" and "restore" instead of leaving a dangling pointer. +static void texture_addref(void *tex) { + if (!tex) + return; + using Fn = void (*)(void *); + vfunc(tex, 10)(tex); +} + +void release_sky_originals() { + for (auto &r : g_sky_orig) { + if (!r.texture) + continue; + using Fn = void (*)(void *); + vfunc(r.texture, 11)(r.texture); + } + g_sky_orig.clear(); +} + void record_orig(const char *material, const char *var, const char *value, void *texture) { if (!material || !var) @@ -725,6 +751,7 @@ void record_orig(const char *material, const char *var, const char *value, if (r.material == material && r.var == var) return; } + texture_addref(texture); g_sky_orig.push_back( {material, var, value ? value : "", texture}); } @@ -750,6 +777,10 @@ int apply_sky_texture() { // where the materials are definitely loaded, and re-upload if needed. bool hdr = sky_uses_hdr_shader(); if (hdr && !g_sky_compensated && !g_sky_pixels.empty()) { + // Always start from the untouched decode so repeated HDR passes cannot + // compound the gamma conversion. + if (!g_sky_pixels_src.empty()) + g_sky_pixels = g_sky_pixels_src; for (size_t i = 0; i + 3 < g_sky_pixels.size(); i += 4) { for (int c = 0; c < 3; c++) { float v = (float)g_sky_pixels[i + (size_t)c] / 255.0f; @@ -822,7 +853,7 @@ void restore_sky_texture() { } void *ms = material_system(); if (!ms) { - g_sky_orig.clear(); + release_sky_originals(); g_sky_applied = false; return; } @@ -833,7 +864,7 @@ void restore_sky_texture() { auto count = vfunc(ms, MATSYS_GetNumMaterials); int total = count(ms); if (total <= 0 || total > 200000) { - g_sky_orig.clear(); + release_sky_originals(); g_sky_applied = false; return; } @@ -866,7 +897,7 @@ void restore_sky_texture() { vfunc(var, 5)(var, r.value.c_str()); } } - g_sky_orig.clear(); + release_sky_originals(); g_sky_applied = false; } @@ -925,6 +956,7 @@ void load_sky_texture() { return; } g_sky_pixels.swap(pixels); + g_sky_pixels_src = g_sky_pixels; g_sky_px_w = w; g_sky_px_h = h; @@ -960,6 +992,13 @@ void skybox_invalidate() { void skybox_reload_image() { restore_sky_texture(); + // Detach the regenerator before dropping the handle: the old texture may + // outlive it and must not call back into a regenerator whose pixels were + // replaced. + if (g_sky_tex) { + using SetRegenFn = void (*)(void *, void *); + vfunc(g_sky_tex, 12)(g_sky_tex, nullptr); + } g_sky_tex = nullptr; g_sky_tex_tried = false; g_sky_reload = true; -- cgit v1.2.3