CS:GO Memory Tool — Process Inspection & Game Security Research
Overview
This started as a question: what exactly is anti-cheat software doing? That question doesn't have a clean answer from the outside. To understand a defender, you have to understand the attack surface it's defending — and the only way to genuinely understand that surface is to build something that works against it.
Over six iterations the tool grew from a basic memory reader into a multi-featured research artefact: a transparent fullscreen ESP overlay drawing entity boxes, health bars, and bone-accurate head circles; an aimbot with Bezier-smoothed angle writes and runtime recoil correction; a triggerbot that monitors a game-internal pointer chain for crosshair contact; and a radar hack that forces enemies visible on the minimap. All running concurrently across seven background threads, configured through a tabbed GUI.
What follows is the project in the order it actually happened — from the first broken C++ compile, through the Cheat Engine offset grind, to each feature built on top of the last. The holdups and dead ends are in here too, because they're where most of the learning was.
The Starting Point — Reading Before Writing
Before touching Cheat Engine or writing a line of code, the first step was conceptual. How does a running Windows process store its game state, and how does any external program reach it?
Windows gives every process its own virtual address space — 4 GB for a 32-bit process like
CS:GO. The game engine allocates objects on the heap at runtime: players, weapons, world
geometry. Those objects live at addresses that shift between sessions due to ASLR, but large
modules like client.dll load at consistent base addresses that can be queried
through the Windows API. Every value visible on screen — player positions, health, view
angles — exists in that address space as raw bytes.
ReadProcessMemory and WriteProcessMemory are the Windows API calls that let a process with the right handle reach into another process's address space and read or modify it. That's the entire primitive. Understanding that these API calls exist, and that anything with the right handle can use them, is the mental model that makes everything else in this project follow naturally.
Before picking a language or framework, it was worth understanding what a view matrix is and why projecting 3D positions onto a 2D screen requires one; what an "entity list" means in a Source-engine game; how view angles are stored and written back. Going in blind with a scanner and no model of the data would have been hopeless. Knowing in advance that a 4×4 float matrix and a flat array of entity pointers had to exist somewhere is what made the search tractable.
Stage 1 — The C++ Attempt, the Compiler, and Windows Defender
The first implementation was in C++, built in Visual Studio. The plan was a native Win32
binary using a handwritten Memory class that wrapped OpenProcess,
ReadProcessMemory, and WriteProcessMemory directly, with a
templated Read<T> and Write<T> interface:
template <typename T>
constexpr const T Read(const std::uintptr_t& address) const noexcept
{
T value = { };
::ReadProcessMemory(processHandle,
reinterpret_cast<const void*>(address), &value, sizeof(T), NULL);
return value;
}
template <typename T>
constexpr void Write(const std::uintptr_t& address, const T& value) const noexcept
{
::WriteProcessMemory(processHandle,
reinterpret_cast<void*>(address), &value, sizeof(T), NULL);
}
This didn't compile on the first try. It didn't compile on the second try either. The errors were instructive:
The main loop was while (True). Correct Python, hard build error in C++.
The language is case-sensitive; the boolean literal is true. This is a
specific symptom of switching between languages — Python conventions bleed into C++
unconsciously. The compiler catches it immediately and refuses to proceed.
std::chrono::miliseconds doesn't exist. The correct spelling is
milliseconds. In Python a typo like this only surfaces at runtime when the
line executes. In C++ the template instantiation fails at compile time. The difference
matters when you're writing code that interacts with system resources — you want these
failures early.
std::this_thread::sleep_for requires #include <thread>
and #include <chrono>. Without them the linker reports an unresolved
external symbol. Python's import system obscures dependency chains; C++ makes every one
explicit. Reading "unresolved external symbol" errors teaches how the linker works, which
is directly applicable to understanding DLL injection and function hooking at the binary
level.
Classic Offensive is a 32-bit process. Building the tool as x64 creates a bitness
mismatch — OpenProcess can still attach, but pointer-width arithmetic is
wrong and memory reads return garbage. Selecting the x86 platform target in Visual Studio
is a forced encounter with the same bitness considerations that matter when writing
shellcode or choosing between 32-bit and 64-bit exploitation paths.
Once those were fixed, the binary compiled cleanly — and then disappeared. The
.exe was nowhere in the output directory. No error, no crash, just absence.
Windows Defender had quarantined it silently before it could run.
This is expected behaviour. A native binary that statically imports OpenProcess,
ReadProcessMemory, and WriteProcessMemory carries a capability
fingerprint that matches process injection tools and credential theft malware. The AV
doesn't know the binary is a research tool; it sees the import table and acts on what
those imports imply. The fix — adding a build output folder exclusion to Defender — is
operational knowledge that matters in both directions. A malware analyst triaging an
unknown sample runs the same analysis in reverse: import table first (what can this binary
do?), then strings, then dynamic behaviour. Understanding why the binary was
flagged is understanding how that first-pass triage works — and why attackers resolve
sensitive imports dynamically via GetProcAddress rather than declaring them
statically.
The C++ prototype ran as a minimal radar hack once the exclusion was in place. But the
iteration speed was wrong for a research workflow. Every offset change — and offsets were
going to change constantly during the Cheat Engine sessions — required a full recompile.
The switch to Python and pymem was a tooling decision: pymem
wraps the same Win32 calls in a Python interface with typed read methods
(read_float, read_int, read_uint), and an offset
change is a one-line edit followed by a re-run. For a research loop where the same
constant might need to be re-derived from scratch after a game update, that cycle
time matters enormously.
pm = pymem.Pymem("csgo.exe")
client = pymem.process.module_from_name(pm.process_handle, "client.dll").lpBaseOfDll
If those two lines run without a PermissionError, the handle is open and
the module base is known. Everything else in the project builds from this.
Stage 2 — Radar: Proving the Access Works
The radar was the first feature built and the right one to start with. It needs no
rendering, no math, no overlay — just the core memory-scanning loop in its purest form.
If the radar works, the process attachment works, client.dll's base is
stable, and the scan methodology is valid. Everything that comes after depends on those
three things being true.
The target is CS:CO (Counter-Strike: Classic Offensive) — a community mod that fuses CS:GO's engine with CS:Source's gameplay and assets. This makes it a better learning exercise and a harder offset problem simultaneously. Published CS:GO offset databases (hazedumper, cs2-dumper) exist for the official build. Classic Offensive does not appear in them — it's a hybrid of two engine generations and offsets that match vanilla CS:GO don't match this mod. Every address in the constants block had to be sourced independently against this specific binary. There is no dump to copy from.
Cheat Engine and the Known-Value Scan
Cheat Engine is the tool for offset research. Its core loop — scan for a known value, change that value in-game, scan again for the new value, repeat until one address remains — is the same category of dynamic analysis as a PIN instrumenter or a Frida hook. The game just provides the fastest feedback loop: you can see immediately whether your read makes sense.
The problem with raw scan addresses is that they're heap addresses: they change every
session because ASLR relocates allocations. The goal is always an offset from a stable
base — client.dll, which loads at a reproducible address each run. Cheat
Engine can trace pointer chains back from a dynamic heap address to a module base,
exposing the chain of pointer dereferences the engine itself uses to reach that field.
Finding the Spotted Flag
The radar hack writes a single byte — the "spotted" flag — into each enemy entity, forcing
their position to appear on the minimap. Finding it uses the simplest possible version of
the scan loop: look directly at an enemy so they appear (spotted) on the radar. Freeze the
scan. Look away so they drop off it. Scan again for the value that just changed. The flag
that toggles between those two states is the field. One or two iterations is usually enough
to narrow it to a handful of candidates, and verifying is instant — write true
to the address and watch the radar.
Finding teamNum — The Disambiguation Problem
The entity list holds all entities on the server — both teams plus the local player.
Writing the spotted flag blindly to every entity would mark your own teammates as visible
too, which is useless. The radar needs to skip any entity on the same team as the local
player and only write to enemies. That requires two things: the team number stored inside
each enemy's entity struct, and the local player's own team number to compare against.
Both come from the same field — m_iTeamNum — read from different entity
structs: each enemy's via their slot in the entity list, the local player's via the
localPlayer static pointer covered in the next section.
m_iTeamNum lives at a fixed byte offset inside the entity struct itself.
The struct is a block of memory allocated on the heap when a player connects to the server
— its address changes every session, but its internal layout is fixed. The team number is
always at the same byte position relative to the start of the struct, regardless of where
in the heap that struct ended up. That fixed distance from the struct's base address is
the offset (+0x158 in this build). Every entity in the list has this same
field at this same offset — read it from any entity and you get that entity's team.
Before the radar can selectively target enemies, that offset needs to be found.
Finding m_iTeamNum uses the same scan technique as the spotted flag — it's a
predictable integer: 2 for Terrorist, 3 for Counter-Terrorist — but the disambiguation
step was more involved.
The first scan produced around ten addresses that all changed between 2 and 3 when teams switched. This is normal: the engine mirrors certain values across multiple structures — the player entity struct, the HUD, the scoreboard, cached network state. All of them update together. Only one is the authoritative field inside the player struct that the game's own logic reads when it needs to compare teams.
The disambiguation technique is "Find out what accesses this address" in Cheat Engine.
For each candidate address, this logs every instruction that reads or writes that location
while the game runs. The pattern to look for is a register-plus-offset form —
mov eax, [reg + OFFSET] or cmp [reg + OFFSET], value — because
that means the game is accessing a field at a consistent byte position within a struct,
which is what an entity field looks like at the assembly level. Several of the ten
addresses showed irrelevant access patterns (global variable writes, array-indexed reads
that belonged to the entity list rather than a struct field). The one that settled it was:
cmp dword ptr [esi+00000158], 02
The game's own team comparison logic. Not writing — reading the value at
+0x158 and comparing it to 2. That's the field the game's decision code
actually consults. Two other candidates had shown [ecx + 0xB8] as a write
destination — the engine writes to both fields — but +0x158 is what it reads.
Using the write-only field would mean the filter reads a value the game itself ignores.
Finding localPlayer — The Pointer Scan
The entity list holds a pointer to every player on the server but carries no
"this is you" marker — nothing in the array identifies which slot is the local player.
The game needs fast access to the local player every frame for rendering, HUD updates,
input handling, and physics; iterating all 64 slots each frame to find yourself would
be wasteful. The engine's solution is a dedicated global: a single pointer in
client.dll's static data segment that permanently holds the address of the
local player's entity struct. That pointer is localPlayer.
The entity struct is heap-allocated — its address shifts every session. The
localPlayer pointer lives in the module's static segment, fixed relative to
client.dll's base for the life of the build. Dereference it at runtime and
you get the current session's local player struct with no scanning required — the same
principle behind every module-relative offset in the project: the heap address moves,
the static reference into it does not.
The process: use "Find out what accesses this address" on the confirmed
teamNum address to find the instruction that reads it. That instruction
uses a base register (esi, ecx) that holds the entity struct
address. Read that register — that's the live entity struct address for the session.
Then run a pointer scan: search all of memory for a location that currently holds that
address. The result rooted inside client.dll's static segment is the
localPlayer pointer.
First attempt hit a wall immediately. Cheat Engine's default debugger mode caused an access violation when setting the breakpoint. The fix was switching the debugger method in CE settings from the Windows default to VEH (Vectored Exception Handler). VEH fires before a structured exception handler, which makes it less visible to software monitoring for standard debugger presence. This is a practical encounter with how Windows exception dispatch works — and the same distinction anti-cheat systems use when choosing between different debugger-detection techniques.
The pointer scan itself returned thousands of candidates. Narrowing them requires the
rescan technique: restart the match so the entity struct gets reallocated at a new address,
find that new address using the same register-read method, then rescan the existing results
keeping only entries that now point to the new address. Pointers rooted in
client.dll's static memory follow the new allocation; random heap pointers
that happened to hold the old address don't. Run the cycle two or three times and the list
shrinks from thousands to a handful to one stable client.dll + OFFSET entry.
localPlayer found, the radar hack was fully operational:
radar — forcing entity flags via write
RADAR_FORCE = {0x0934: 256, 0x0978: 1}
RADAR_TOGGLE = {0x0A20: 1, 0x0A24: 1}
for offset, desired in RADAR_FORCE.items():
if pm.read_int(ent_ptr + offset) != desired:
pm.write_int(ent_ptr + offset, desired)
The read-before-write pattern avoids unnecessary writes. VAC-style anti-cheats can monitor write frequency to protected memory regions — only writing when the value has drifted keeps the write volume low. The flag toggling (RADAR_TOGGLE) alternates each loop iteration to keep a watchdog from detecting a static value that never changes naturally.
Seeing the enemy dot appear on the minimap in response to a write from an external process
is the moment the abstraction collapses. The game doesn't know who wrote that byte. This
is also what WriteProcessMemory looks like in process injection — the target
in that case is a shellcode stub rather than a game flag, but the API call is identical.
Establishing that reality early is the point of building the radar first.
Stage 3 — ESP: Mapping the Object Graph
With the radar working and the foundational methodology validated, ESP was next. ESP is where the bulk of the reverse engineering lived, because it meant mapping the engine's object graph rather than flipping a single flag. It had a strict internal dependency order — each step unlocked the next.
The Entity List
The entity list is the base array that holds a pointer to every player in the server.
It lives at a stable offset from client.dll's base:
client.dll + 0x04E2520C. Each entry is indexed by slot and holds an entity
pointer. Everything downstream — reading positions, health, teams, bones — indexes off
this. Finding it used the same scan loop as before, anchored against entity counts that
change predictably as players join and leave the server.
Fingerprinting the Local Player
localPlayer and the entity list index solve two different problems.
localPlayer answers what is my data — dereference it and you have
direct access to your own entity struct: health, position, team number. The radar relies
on this: read my team from localPlayer, compare against each enemy's team,
only write the spotted flag to the ones that differ.
The entity list index answers which slot in the array is me. The ESP and aimbot loop through all 64 entity list slots by number. When the loop reaches your own slot, it needs to skip it — skip drawing a box over yourself, skip selecting yourself as an aimbot target. But the entity list is a flat array of pointers with no self-marker; the slot assigned to you depends on connection order and changes every match.
The fingerprint function bridges both: it uses localPlayer as ground truth —
reading your known position and health directly from the struct — then walks the entity list
comparing each slot against those values. The slot that matches is your index. That index
gets stored once and reused by every loop that needs to skip you. You need both because
localPlayer gives you the data; the fingerprint gives you the position in
the array.
The solution is a fingerprinting loop that runs on a slow 10-second cycle and cross-checks two independent values against a known-good local player pointer:
detect_local_player — position and health cross-checkbase_ptr = pm.read_uint(client + 0x0526108C)
local_health = pm.read_int(base_ptr + 0x100)
local_x = pm.read_float(base_ptr + 0x138)
local_z = pm.read_float(base_ptr + 0x140)
for i in range(MAX_ENTITIES):
d = math.sqrt((ex - local_x)**2 + (ez - local_z)**2)
if d < 2.0 and abs(eh - local_health) < 1 and d < closest_dist:
closest_idx = i
The local player's raw data — position and health — comes from the
localPlayer pointer resolved in Stage 2 (client + 0x0526108C).
localPlayer gives direct struct access without touching the entity list.
What it doesn't expose is the entity list index — the slot number the ESP and
aimbot loops need to skip drawing over yourself and to exclude you from aim target
selection. The cross-check here bridges both access paths: use the known-good values
from localPlayer to identify which entity list slot belongs to the
local player.
For every entity slot, compute the 2D ground-plane distance between the entity's XZ position and the known local position. Y is omitted because vertical position can differ slightly depending on how the engine interpolates the local player versus the networked entity representation. The 2.0 world-unit threshold is tight enough to exclude any other player but loose enough to absorb that representation difference.
health cross-checkPosition alone isn't sufficient — two entities can briefly share the same spawn position. The health cross-check adds a second independent dimension. Both conditions must be true simultaneously, making a false positive essentially impossible in practice.
10-second refresh rateThe local entity index is stable within a round — it only changes when the player disconnects and reconnects. Running this scan every frame would burn 64 memory reads every 10ms for a value that virtually never changes. The slow refresh is the same reasoning behind the radar's read-before-write: minimise unnecessary access volume.
The Overlay
Drawing on top of the game requires a surface that is: fullscreen, always on top, visually transparent, and non-interactive (mouse clicks must fall through to the game). Tkinter provides the drawing surface; two Win32 extended window style flags handle the rest.
overlay setup — transparent, click-throughroot.attributes("-topmost", True)
root.attributes("-transparentcolor", "black")
root.overrideredirect(True)
root.geometry(f"{sw}x{sh}+0+0")
canvas = tk.Canvas(root, width=sw, height=sh, bg="black", highlightthickness=0)
hwnd = ctypes.windll.user32.FindWindowW(None, root.title())
make_window_clickthrough(hwnd)
win32 click-through via ctypes
GWL_EXSTYLE = -20 WS_EX_LAYERED = 0x80000 WS_EX_TRANSPARENT = 0x20 LWA_COLORKEY = 0x1 styles = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE) styles |= WS_EX_LAYERED | WS_EX_TRANSPARENT ctypes.windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, styles) ctypes.windll.user32.SetLayeredWindowAttributes(hwnd, 0x000000, 0, LWA_COLORKEY)
WS_EX_LAYERED enables color-key transparency — anything drawn in pure black
becomes invisible. WS_EX_TRANSPARENT makes the window non-interactive: the OS
routes mouse and keyboard events to whatever window is below it in Z-order, which is the
game.
The first bug here was silent and took a while to diagnose. The overlay loaded fine
visually — transparent, fullscreen, drawing correctly — but mouse input to the game was
completely blocked. The cause: Tkinter's HWND doesn't exist until after the first layout
pass completes. Calling FindWindowW immediately after
root.geometry() returns NULL because the window isn't registered yet.
root.update_idletasks() forces the layout pass before the Win32 call. Without
it, FindWindowW returns NULL silently, SetWindowLongW never runs,
and WS_EX_TRANSPARENT is never set. Nothing visibly broke until you tried to
click something in-game — a failure mode that doesn't announce itself.
Finding the View Matrix — The Breakthrough
The view matrix was the hardest single thing to find in the entire project, and finding it was the moment everything changed. Before it, ESP existed in a rough form — boxes drawn at estimated screen positions using fixed offsets and a lot of guesswork. The aimbot existed in an even rougher form: read the enemy's world coordinates, compute a direction vector, write the angle. It worked loosely but had no real precision — no concept of where on the player to aim, no ability to distinguish the head from the feet at the pixel level. It was pointing in the right direction, not aiming at anything specific.
The view matrix fixed all of that at once. A 4×4 block of 16 floats that encodes the camera's full orientation and projection transform — once that address was found, any world-space coordinate could be projected onto the screen with pixel accuracy. The ESP boxes snapped into correct alignment. The head circle, previously estimated as a rough offset, became a precise projection of the actual head bone. And the aimbot — which had been shooting in the general direction of a player's feet — could now target the exact bone position with the angle math working correctly against real 3D geometry rather than approximated screen positions. Everything that came after depended on this one address.
The challenge is that unlike health or team number, you can't anchor the scan against
a UI-readable value. You're searching for a structure whose 16 entries are derived
continuously from camera angles — they change every frame as you move the mouse. There's
no obvious number to type into the search box. It took significantly longer to find than
any other offset in the project, and several false candidates looked plausible until the
confirmation test exposed them. CS:CO's developer console is what eventually made it
tractable: cl_showpos 1 shows your current world position on screen, and
more importantly, setang lets you snap the camera to exact angle values,
which makes the matrix entries mathematically predictable.
The Angle-Based Scan
setang is a Source engine console command that sets pitch, yaw, and roll to
exact values. Running setang 0 0 0 locks the camera to pitch = 0°, yaw = 0°,
roll = 0° — not approximately, exactly. At that orientation the rotation entries of the
view matrix encode cos(0°) = 1.0 and sin(0°) = 0.0 with no
rounding. Scan for the float 1.0, then run setang 0 90 0 so
those same entries now encode cos(90°) ≈ 0.0 and
sin(90°) = 1.0, and scan again. Repeat at 180° and 270°. Very few floats
in memory cycle between exactly 1, 0, −1, and 0 as you step through cardinal angles.
The candidate list drops sharply each pass — what setang provides that
manual aiming can't is certainty: you know the value precisely, not approximately, so
the scan can filter hard.
The Change-Pattern Scan
If the angle scan still leaves too many candidates, a second pass runs in parallel: scan for "Changed value" while continuously rotating the camera, then immediately scan for "Unchanged value" while holding perfectly still. Matrix entries change while the camera moves and freeze when it stops. A few rounds of spin / freeze / scan narrows the field to addresses that are definitely camera-derived. Cross-referencing this against the angle scan result usually converges quickly.
Confirming the Matrix
Candidate addresses need to be verified before trusting them. Two checks: first, read
16 consecutive floats and verify the top-left 3×3 submatrix is approximately orthonormal —
the three row vectors should each have magnitude close to 1.0 and their dot products
should be close to 0. A random address that happens to hold plausible-looking floats
usually fails this. Second, and more conclusively: run a known world position through the
candidate matrix using the projection formula and verify the output pixel matches where
that point actually appears on screen. Use cl_showpos 1 to read your current
world coordinates, pick a wall or landmark at a known distance, and back-calculate the
expected screen position. If the candidate produces the right pixel, it's correct.
Several addresses passed the orthonormality check and failed the projection test — matrix-shaped data that wasn't the view matrix. The projection test is the conclusive one. When it finally passed, and enemy positions started landing correctly on screen for the first time, it was immediately obvious. The boxes that had been drifting and misaligned snapped into place. The head circle landed on the head. The aimbot started writing angles that actually pointed at the bone rather than somewhere near the player. One address, and everything connected.
For this build the view matrix lives at client.dll + 0x4DF85A4. The
technique matters more than the constant — any update that shifts the module layout
requires re-sourcing the address from scratch using the same approach.
World-to-Screen Projection
With the matrix address confirmed, projection is straightforward. The view matrix is a 4×4 float matrix that encodes the camera's orientation and projection transform. Multiplying a 3D world coordinate through it produces a clip-space coordinate that normalises to a pixel position:
world_to_screen — clip-space projectiondef world_to_screen(pos, matrix, sw, sh):
x, y, z = pos
clip_x = x*matrix[0] + y*matrix[1] + z*matrix[2] + matrix[3]
clip_y = x*matrix[4] + y*matrix[5] + z*matrix[6] + matrix[7]
clip_w = x*matrix[12] + y*matrix[13] + z*matrix[14] + matrix[15]
if clip_w < 0.01:
return None
ndc_x = clip_x / clip_w
ndc_y = clip_y / clip_w
return int(sw/2 + ndc_x*sw/2), int(sh/2 - ndc_y*sh/2)
The view matrix is 16 floats laid out row-major. Multiplying the world position
(x, y, z, 1) through it produces three clip-space components.
clip_x and clip_y use rows 0 and 1. clip_w uses
row 3 (indices 12–15) — the perspective row that encodes depth. The implicit fourth input
component is 1, which is why the translation terms (matrix[3],
matrix[7], matrix[15]) are added directly.
clip_w encodes how far the point is from the camera along the view axis.
Dividing clip_x and clip_y by it produces NDC (Normalized
Device Coordinates) in the range [−1, 1]. This is the perspective divide — it shrinks
coordinates for distant points and expands them for close ones, producing natural
foreshortening.
ndc_x = −1 is the left edge, +1 is the right. Formula:
sw/2 + ndc_x * sw/2. For Y the sign flips — NDC Y increases upward but
screen Y increases downward: sh/2 − ndc_y * sh/2.
If clip_w is negative the point is behind the camera. Early versions
omitted this guard — entities directly behind the player drew boxes in the wrong screen
quadrant because dividing by a negative clip_w inverts the sign of both NDC
values. The guard short-circuits it by returning None. A silent visual bug
that only appeared when turning around quickly.
Box Drawing and Crouch Compensation
For each entity: read the entity pointer, skip nulls and dead entities, project foot and head positions, derive the box from the pixel distance between them:
entity loop — box constructionfeet_screen = world_to_screen((ex, ey, ez), view_matrix, sw, sh) head_screen = world_to_screen((ex, ey, head_z), view_matrix, sw, sh) box_h = fy - hy box_w = box_h // 2 x1, y1 = fx - box_w//2, hy x2, y2 = fx + box_w//2, fy canvas.create_rectangle(x1, y1, x2, y2, outline=box_color, width=2)
Head height is not a fixed offset. Players can crouch, and the animation transition is smooth. Without accounting for it, crouched players show a box that overshoots the head. A delta value interpolated from a crouch float corrects this:
delta = max(0.0, min(1.0, (72.0 - crouch) / 18.0)) head_z = ez + 70 - (HEAD_OFFSET * delta)
Bone Matrices — The Hard One
The flat box worked but felt imprecise. The game stores a bone matrix for each entity at
entity_ptr + 0x26A0 — an array of 4×3 float matrices, one per bone. Reading
the highest-Z bone among IDs 6, 8, and 12 (neck/head area) gives an exact world-space
head position. Finding the pointer and the per-bone stride of 0x30 was the
single hardest piece of the whole project:
bm_ptr = pm.read_uint(ent_ptr + 0x26A0)
best_bone = None
highest_bz = -float("inf")
for bone_id in [6, 8, 12]:
off = bone_id * 0x30
bx_ = pm.read_float(bm_ptr + off + 0x0C)
by_ = pm.read_float(bm_ptr + off + 0x1C)
bz_ = pm.read_float(bm_ptr + off + 0x2C)
if bz_ > highest_bz:
highest_bz = bz_
best_bone = (bx_, by_, bz_)
The bone matrix paid off twice. For ESP: the highest head bone gives an exact point to draw a head circle on, and the full bone set enables a skeleton overlay instead of just a flat box. For the aimbot — which hadn't been started yet — it would give a real head position to target. Without the bone matrix, an aimbot can only estimate the head as a fixed offset above the feet, which drifts whenever the player crouches, leans, or plays an animation. One piece of reverse engineering; two payoffs.
Flicker — The Tkinter Threading Problem
Early ESP versions flickered badly. The cause: calling canvas.create_rectangle()
from a background thread. Tkinter's internal state is not thread-safe — concurrent access
from the ESP thread and the main event loop produced intermittent corruption and visual
tearing. The fix was ensuring all canvas operations ran on the main thread. The final
architecture runs the ESP loop as the main thread content and uses
time.sleep(0.03) as the frame gate, giving ~33 fps without any cross-thread
canvas access.
Threading Architecture
By the end of Stage 3 there were already multiple concurrent concerns: entity data needed to update at a different rate than the drawing loop. The architecture that emerged across the whole project is a thread-per-feature model using a shared dict as the message bus:
| Thread | Interval | Role |
|---|---|---|
| detect_local_player | 10 s | Fingerprints local entity index and team |
| update_entity_info | 100 ms | Fills entity position cache |
| esp_loop | 30 ms | Owns all canvas drawing (≈33 fps) |
| aimbot_thread | 10 ms | Reads angles, writes corrected values |
| radar_thread | 50 ms | Writes entity visibility flags |
| triggerbot_thread | 10 ms | Monitors pointer chain, fires on change |
| combined_hotkey_listener | event | pynput callbacks for all hotkeys |
Race conditions exist but are tolerated. Python's GIL makes individual dict reads and
writes atomically safe for simple scalar values — a stale read produces at worst a
miscoloured box for one frame, not a crash. In production code this warrants
threading.Lock() around critical sections. Here it was a conscious tradeoff
given the cosmetic-only failure mode.
Stage 4 — Aimbot: Writing Back
Reading game state is one kind of access. Writing to it is another. With entity positions
and bone matrices solved, the aimbot was next: read the current view angles, compute where
they need to point, write the corrected values back — overriding what the mouse produced.
This is WriteProcessMemory used offensively.
Finding the View Angle Pointer
View angles (pitch and yaw, stored as floats) live behind a two-level pointer rooted
in stdshader_dx9.dll — a non-obvious location that took another CE pointer
scan to find. The same methodology as localPlayer: scan for a known value
(current pitch while looking at a fixed point), trace back through the access instruction
to the base register, scan for what holds that address in a module's static segment.
Angle Math and the Yaw Wrap Problem
Computing the required angle to a target is straightforward trigonometry:
angle calculation — atan2 pitch/yawdef calc_angle(src, dst):
dx, dy, dz = dst[0]-src[0], dst[1]-src[1], dst[2]-src[2]
hyp = math.sqrt(dx*dx + dy*dy)
return -math.degrees(math.atan2(dz, hyp)), math.degrees(math.atan2(dy, dx))
Pitch is negated because CS:GO's coordinate system has positive pitch pointing downward.
Yaw wrapping is the subtle problem. Angles live in (−180, 180] and when a target crosses
the ±180° boundary, naive subtraction gives a ~360° jump instead of a small step.
angle_diff normalises it:
def angle_diff(a1, a2):
dp = a1[0] - a2[0]
dy = ((a1[1]-a2[1])+180) % 360 - 180
return math.sqrt(dp*dp + dy*dy)
Imagine the local player faces 179° and the target is at −179°. The real gap is 2°.
Naive subtraction gives 179 − (−179) = 358° — the long way around the
circle. This makes the target appear to be 358° outside the FOV cone and it's never
selected. The formula ((a1−a2) + 180) % 360 − 180 fixes it: the result is
always the shortest angular distance between two headings regardless of which side of the
±180° boundary they sit on. This same issue appeared again later inside the Bezier
interpolation and had to be fixed there too.
Bezier Smoothing
Writing the corrected angle directly to memory in one frame produces a velocity spike — an instantaneous delta of potentially 90°+ in 10ms, which is physically impossible for a human wrist. Anti-cheat systems that log view angle deltas per frame flag this as a statistical outlier. The aim interpolates along a cubic Bezier curve instead:
bezier smooth angle — cubic interpolationdef bezier_smooth_angle(current, target, t):
p0, y0 = current
p3, y3 = target
p1 = p0 + (p3-p0)*0.25
p2 = p0 + (p3-p0)*0.75
y1 = y0 + ((y3-y0+180)%360-180)*0.25
y2 = y0 + ((y3-y0+180)%360-180)*0.75
return bezier_point(p0,p1,p2,p3,t), bezier_point(y0,y1,y2,y3,t)
bezier_point evaluates
B(t) = (1−t)³·P0 + 3(1−t)²t·P1 + 3(1−t)t²·P2 + t³·P3.
At t = 0 the result is exactly P0 (current angle). At t = 1
the result is exactly P3 (target angle). Between them the four terms blend smoothly,
weights always summing to 1.
Placing the control points at 25% and 75% along the direct line from start to target keeps the curve on the direct angular path — no sideways deviation — while the cubic weighting still produces natural acceleration at the start and deceleration at the end. This is what makes per-frame deltas statistically indistinguishable from fast human mouse input.
yaw wrapping inside bezierThe same shortest-path formula from angle_diff is applied to the control
point offsets. Without it, interpolating across the ±180° boundary sweeps 340° the long
way around the circle instead of the 20° short way.
FOV Cone
The aimbot only considers targets within a configurable angular distance from the current
crosshair — the FOV cone. This is controlled by desired_fov (1°–30°):
best_fov = shared["desired_fov"]
...
fov = angle_diff(angle, (cp, cy))
if fov < best_fov:
best_fov = fov
best_angle = angle
new_target = ent_ptr
Among all enemies inside the cone, the one with the smallest angular offset wins. A tight FOV (1°–3°) requires manual pre-aim near the head before the lock engages. A wide FOV (15°–30°) activates across much of the screen and is a statistical signature detectable by timing analysis — a wide-FOV lock that engages without prior manual aim is not plausible human input. Keeping it small keeps the behaviour within the range of what a precise human player could produce.
Recoil Correction
Writing the correct angle to a stationary target is one problem. Keeping it there while the gun fires is another. Automatic weapons produce upward spray — the game shifts the view angle upward with each shot, overriding the previous write. The correction is a running pitch offset that accumulates while firing:
recoil compensation — live pitch offsetif shared["firing"]:
if shared["left_hold_start"] is None:
shared["left_hold_start"] = time.time()
if time.time() - shared["left_hold_start"] >= 0.05:
shared["current_pitch_correction"] = min(
shared["current_pitch_correction"] + shared["correction_speed_2"] * shared["recoil"],
54.0
)
Getting correction_speed_2 right required iterative live testing — too fast
and the aim overcorrects and dips below the target, too slow and it can't keep pace with
the spray. F4/F5/F6 hotkeys set presets (40 / 55 / 20) because the right value varies
by weapon fire rate. This was one of the few parts of the project that required genuine
trial-and-error tuning against live gameplay rather than derivation from memory structure.
The pointer chain for the view angles was also fragile across game updates — the same break-and-re-source cycle from the offset research phase, just applied to a more deeply nested chain rooted in a different module. The patience required is the same each time.
Stage 5 — Triggerbot: Work in Progress
The triggerbot was the last feature added and is the least finished. It doesn't detect crosshair contact geometrically — it monitors a specific memory value via a pointer chain and fires the mouse when that value changes. When the crosshair crosses an enemy hitbox, a game-internal counter updates. The triggerbot watches that counter:
triggerbot — pointer chain and fire logicTB_POINTER_BASE = 0x00DEF97C
TB_OFFSETS = [0x24, 0x4, 0x68]
ptr = tb_resolve_pointer(pm, client + TB_POINTER_BASE, TB_OFFSETS)
value = tb_read_value(pm, ptr, "FLOAT")
elif value != prev_value:
m.press(mouse.Button.left)
time.sleep(0.05)
m.release(mouse.Button.left)
This approach is more robust than geometric detection — it uses the game's own hit detection logic rather than external math that might disagree with the server. The tradeoff is brittleness: the pointer chain breaks on game updates and requires re-sourcing from Cheat Engine each time. The triggerbot hasn't had the same methodical hardening as the aimbot; it's a quick first pass that works but has known rough edges.
A zero-latency instant click is a statistical anomaly — no human reaction time is that fast. Anti-cheat systems that maintain timing histograms of click events will flag inputs outside the human-achievable minimum latency. A slider in the GUI exposes this directly: the delay is tunable from 0 to 250ms at runtime, making the tradeoff between responsiveness and detectability something you have to actively reason about rather than a hardcoded constant someone else chose.
How This Maps to Security Research
Every component of this project has a direct parallel in real security work. The mapping
is not metaphorical — it is the same API calls, the same methodology, applied to a
different target. The reason to build it is that you cannot fake the understanding that
comes from having actually done it. Reading about ReadProcessMemory is not
the same as watching Windows Defender quarantine your binary because of what
ReadProcessMemory in the import table implies about intent.
ReadProcessMemory as a Research Primitive
This is exactly what a debugger does when it reads a target process's memory. What a
memory forensics tool does against a live malware sample in Volatility or Rekall. What a
vulnerability researcher does when they attach to a process under x64dbg and inspect the
heap after a controlled allocation. The primitive is ReadProcessMemory in all
cases. Building a tool that calls it directly — without the debugger abstraction layer —
makes the underlying mechanism concrete in a way that reading documentation does not.
AV Heuristics and the Import Table
The C++ prototype being quarantined before it ever ran is a security lesson in its own
right. The binary did nothing — it hadn't executed. The AV acted on static characteristics:
OpenProcess, ReadProcessMemory, and WriteProcessMemory
together constitute a capability fingerprint that matches process injection tools and
credential theft malware. A malware analyst triaging an unknown binary runs the same
analysis in the same order: import table first, then strings, then dynamic behaviour.
Understanding why the tool was flagged is understanding how that first-pass triage works.
It also surfaces the bypass: resolving imports dynamically via GetProcAddress
at runtime removes them from the static import table and defeats that heuristic. The
heuristic and the bypass are two sides of the same knowledge.
Dynamic Analysis with a Known-Value Scan
The Cheat Engine methodology — known value, change, narrow, repeat — is a specific
instance of using known program outputs to locate the internal state that produced them.
In exploit research: allocate a controlled-size buffer, locate it in the heap, establish
its offset from a disclosure gadget. In firmware analysis: send a known command, find the
response buffer, trace backward to the processing function. The game provides the fastest
feedback loop. The cmp dword ptr [esi+00000158], 02 instruction that confirmed
the teamNum offset is the same disassembly view a researcher sees when breaking on a
comparison in a binary they're reversing.
Pointer Chain Traversal
Walking a multi-level pointer chain to locate a target object in a live heap is the same
skill used in use-after-free exploitation: understanding object lifetimes, pointer graphs,
and what memory looks like after a free. It's the same skill used when tracing how a
global pointer leads to an object, leads to a vtable, leads to a function in IDA. The
localPlayer pointer scan — thousands of candidates, multiple rescan cycles,
VEH debugger to bypass the access violation — is what that analysis looks like when it
requires patience rather than just knowledge. The patience is non-trivial and it doesn't
come from reading about it.
WriteProcessMemory and Process Injection
The aimbot writes floats to view angle addresses. The radar writes integers to entity flag
fields. The API call for both is WriteProcessMemory. This is structurally
identical to the write step in shellcode injection: allocate memory in the target process
with VirtualAllocEx, write the payload with WriteProcessMemory,
execute it with CreateRemoteThread. The target here is a game field rather
than a shellcode stub, but the Windows API call is identical. Building something that
exercises the write primitive makes the mechanism real before approaching more constrained
injection scenarios.
Anti-Cheat as the Defender Model
VAC, EAC, and Vanguard are doing the same thing this tool does, from the other direction:
scanning for unauthorised ReadProcessMemory calls, looking for injected
modules in the process module list, monitoring write frequency to protected addresses,
checking for RWX memory regions that don't correspond to a loaded DLL. The triggerbot's
humanisation delays are a direct response to timing-histogram detection. The Bezier
smoothing is a direct response to angular velocity spike detection. Building the attacker
side is the prerequisite for understanding what the defender must actually measure —
which is the prerequisite for building detection that works. You cannot reason clearly
about one without having understood the other.
All the techniques here operate in userspace, which means they're visible to any
kernel-mode monitor. A ReadProcessMemory call is observable via kernel
callbacks (PsSetLoadImageNotifyRoutine, ETW providers, minifilter drivers).
Kernel-mode anti-cheat like Vanguard exists precisely because userspace detection is
bypassable with userspace tools — the same tools this project is built with. Understanding
what moves to ring-0 to become undetectable is the natural next question.
Known Issues & Open Problems
This is the honest state of each feature. Most of what's listed here isn't a failure of understanding — it's the gap between a first working implementation and a hardened one. Documenting it is part of the point.
Triggerbot — Fires on Teammates
The triggerbot monitors a game-internal pointer chain for crosshair contact and fires when the value changes. The problem: that value changes on any entity crossing the crosshair, not only enemies. Teammates trigger it just as reliably as enemies do. The current implementation has no team filter — it doesn't cross-reference the entity under the crosshair against the local player's team before firing.
The fix is conceptually straightforward: resolve which entity the crosshair is over at
the moment of trigger, read its m_iTeamNum, and suppress the click if the
teams match. The difficulty is that the pointer chain used by the triggerbot leads to a
raw floating-point value that changes on contact — it doesn't expose the entity index
directly. Bridging the triggerbot's contact signal to the entity list to retrieve the
team field is the open engineering problem. Until it's solved, the triggerbot can only
be used safely when there are no friendly players in the field of view.
Aimbot — Ghost Flicks
The aimbot occasionally snaps toward a position in empty space — a brief flick to a location where no visible enemy exists. This is a ghost box problem downstream: the aimbot targets whatever the entity list and bone matrix resolve to, and if the entity list contains a ghost entry (see below), the aimbot will aim at it with full confidence. The aimbot itself has no way to distinguish a real enemy from a stale entity struct — it reads the bone position and writes the angle regardless. Solving it requires solving the ghost box problem at the entity filtering level, not at the aimbot level.
ESP — Ghost Boxes
The ESP draws boxes around entities whose health reads as greater than zero. In practice it sometimes draws boxes at positions where no enemy is actually present — boxes that float in the air, pass through walls, or sit at a location an enemy occupied minutes ago. These are ghost boxes: entries in the entity list that are no longer active players but whose struct still contains partially populated data that passes the alive-and-valid checks.
One hypothesis for why they appear consistently in this mod: Classic Offensive may implement server-side entity culling similar to how Valorant handles it — where the server only sends enemy position data to your client when those enemies are close enough to be relevant, keeping the rest in a stale or dormant state. In Valorant this is an explicit anti-wallhack measure; the client literally doesn't have position data for enemies outside the fog-of-war radius. A Source-engine mod built on CS:GO's framework could have a comparable system where entities outside a proximity threshold aren't updated by the server, leaving their struct values frozen at their last known position. The ESP reads those frozen values and draws a box, but the enemy may be on the other side of the map — or may have disconnected entirely.
A ghost detection heuristic was attempted: flag entities whose XYZ position hasn't changed across several consecutive reads as potential ghosts and suppress them. It doesn't work reliably. The fundamental flaw is that a real player camping at a fixed position will also produce an unchanging XYZ value — a player holding an angle from behind cover, or standing still watching a site, is indistinguishable from a stale entity struct by position alone. A legitimate camper would be invisible to the ESP while the ghost detection is active, which inverts the tool's purpose. The heuristic was removed; the ghost box problem remains open. A better approach would cross-reference against the active player bitmask the engine maintains, but that field hasn't been located yet for this build.
Aimbot — Recoil Compensation Is Crude
The current recoil correction is a time-based pitch accumulator that ramps up while the left mouse button is held and caps at 54 degrees. It requires weapon-specific presets (F4/F5/F6) because the right accumulation rate varies by fire rate and spray pattern. Get it slightly wrong and the aim either overcorrects and dips below the target, or lags behind the spray and drifts upward. It is tuned manually against live gameplay, not derived from anything the game actually knows.
The more elegant solution reads recoil compensation directly from the game's own data.
The Source engine stores the current punch angle — the recoil offset the game is applying
to the view — in the local player struct as m_vecPunchAngle. That value is
already the exact correction needed: it is what the game is adding to the base view angle
to produce the spray. Subtracting it from the desired aim angle before writing gives
frame-accurate recoil correction with no presets, no tuning, and no weapon-specific
constants. It would work correctly for every weapon automatically because it reads the
value the engine is already computing rather than trying to approximate it from a timer.
The offset for m_vecPunchAngle on this build hasn't been found yet, which
is why the crude version is still in place.
ESP — CT Skeleton Broken
The skeleton overlay works correctly on Terrorist player models and produces a garbled, incorrect result on Counter-Terrorist models. The bone matrix read logic uses bone IDs 6, 8, and 12 for the head/neck region — these IDs were found by iterating through bone positions on a T-side model and identifying the ones with the highest Z values (the head bones). CT player models use a different skeleton hierarchy with different bone IDs for the equivalent joints. The same IDs read against a CT model land on different bones — shoulders, spine, or nowhere meaningful — producing a skeleton that is connected incorrectly and drawn in the wrong positions.
Finding the correct bone IDs for the CT skeleton requires repeating the bone matrix iteration on a CT-side model specifically: force-joining CT, freezing a bot in a known pose, iterating all bone IDs and logging their world-space Z values, then identifying which IDs correspond to the head and upper body. This hasn't been done yet. Until it is, the skeleton overlay is disabled on CT entities and only the flat bounding box is drawn.
fig. 2 — CT skeleton bug: bone IDs sourced from T model map to incorrect joints on CT hierarchy
Current State & What Comes Next
The tool is a research artefact built against a specific target: CS:CO (Counter-Strike: Classic Offensive), a community mod that blends CS:GO, Counter-Strike: Source, and CS 1.6 into a single client. Its hybrid engine lineage means the offsets don't cleanly match any published offset list. Every address was sourced independently against this specific binary. The triggerbot is the most obviously unfinished part — it works but hasn't had the same methodical hardening as the earlier stages, and the pointer chain it monitors is brittle.
The natural next direction is kernel-level work. Everything here is userspace, which means it's visible to a kernel-mode monitor. Understanding what moves to ring-0 — driver development, IOCTL analysis, kernel vulnerability research — is the same question the GPU passthrough project approaches from the anti-cheat evasion angle. The two projects are the same question asked from opposite sides.
Note. Built on hardware and a game licence I own, as a personal research project for understanding Windows process memory, the Win32 security model, and the technical basis of game anti-cheat. Not used in online multiplayer against other players.
POC:
fig. 1 — Proof of Concept