A Team Fortress 2 game cheat written in Rust for Linux, intended as an educational reference for understanding shared library injection, vtable hooking, and Source engine internals.
Note: This project targets the Linux version of TF2 and is intended for use on private servers, in single-player, or in other contexts where you have authorization to modify the game client. Use responsibly.
This project is a port and evolution of tf_c, an earlier C implementation of the same concepts. The rewrite in Rust adds type safety, a richer config system, and a more structured codebase — while covering the same underlying techniques. Both projects use Nuklear for the in-game GUI, but tf_rs wraps it in an idiomatic Rust API with builder-style chaining rather than calling the C bindings directly.
- Overview
- Project Structure
- How It Works — High Level
- Injection
- Library Initialization & Cleanup
- Source Engine Interface Resolution
- Hooking
- Memory Reading Patterns
- Features
- The Nuklear UI Layer
- Building & Running
- Dependencies
tf_rs is a shared library (.so) that is injected into a running TF2 process on Linux. Once loaded, it:
- Resolves Source engine interfaces from already-loaded shared libraries
- Hooks key engine functions to intercept game logic and rendering
- Draws an in-game overlay and menu using the Nuklear immediate-mode GUI library rendered into a second OpenGL context
- Provides aimbot, ESP, movement assistance, and player tracking features
The codebase demonstrates several low-level techniques that are useful for understanding game internals, reverse engineering, and unsafe Rust:
- ELF
.init_array/.fini_arrayfor constructor/destructor execution ondlopen/dlclose - Source engine
CreateInterfacefactory pattern - VTable hooking via
mprotect+ direct memory writes (see the VTable Hooking guide) - SDL2 function pointer hooking through JMP stub resolution
- RIP-relative address calculation for finding non-exported globals
- Raw pointer casting and offset-based struct field access in Rust
tf_rs/ ← Workspace root
├── tf_rs/ ← Main cdylib (the injected .so)
│ ├── src/
│ │ ├── lib.rs ← Entry point: .init_array / .fini_array
│ │ ├── config/ ← Persistent config with custom serialization
│ │ ├── features/
│ │ │ ├── aimbot.rs ← Target selection & aim angle calculation
│ │ │ ├── esp.rs ← On-screen overlay drawing
│ │ │ ├── menu.rs ← Nuklear in-game menu
│ │ │ ├── movement.rs ← Bunnyhop
│ │ │ └── spectator_list.rs
│ │ ├── globals/ ← Shared runtime state (RwLock)
│ │ ├── helpers/ ← Math utilities + helper macros
│ │ ├── hooks/
│ │ │ ├── core.rs ← Hook registration and global hook state
│ │ │ ├── create_move.rs ← Hook: game input frame
│ │ │ ├── paint_traverse.rs ← Hook: per-panel render pass
│ │ │ ├── poll_event.rs ← Hook: SDL event polling
│ │ │ ├── swap_window.rs ← Hook: SDL frame swap (menu/overlay)
│ │ │ ├── vtablehook.rs ← VTable hook implementation
│ │ │ ├── sdlhook.rs ← SDL function pointer hook
│ │ │ └── fn_sig.rs ← Type-safe function signature wrapper
│ │ ├── interfaces/ ← Source engine interface wrappers
│ │ ├── player_db.rs ← Per-GUID player category database
│ │ ├── traits/ ← FromRaw trait for pointer casting
│ │ └── types/ ← Entity, Player, Weapon, Vec3, etc.
│ └── build.rs ← Links SDL2 + GLEW
├── inject/ ← Injector binary
│ ├── src/main.rs ← Builds tf_rs, finds PID, calls inject script
│ └── so_inject.sh ← GDB-based dlopen injector
├── nuklear/ ← Safe Rust wrapper around nuklear_sys
└── nuklear_sys/ ← bindgen FFI bindings + C implementation
┌─────────────────────────────────────────────────────────────┐
│ cargo inject │
│ → builds tf_rs.so │
│ → finds tf_linux64 PID via pidof │
│ → calls so_inject.sh inject <PID> <path/to/tf_rs.so> │
└───────────────────────┬─────────────────────────────────────┘
│ GDB attaches, calls dlopen()
▼
┌─────────────────────────────────────────────────────────────┐
│ ELF .init_array fires → init() runs │
│ → Interfaces::init() (resolve engine interfaces) │
│ → player_db::load() (load saved player categories) │
│ → Hooks::init() (install hooks) │
└───────────────────────┬─────────────────────────────────────┘
│ Game continues running
▼
┌─────────────────────────────────────────────────────────────┐
│ Per-frame hooks fire: │
│ CreateMove → aimbot, bunnyhop, thirdperson │
│ PaintTraverse → ESP overlay │
│ SDL_PollEvent → input capture for menu/keybinds │
│ SDL_GL_SwapWindow → Nuklear menu + spectator list │
└─────────────────────────────────────────────────────────────┘
Injection uses gdb to attach to the running game process and call dlopen() on the compiled .so. The injector (inject/) automates this:
cargo build -p tf_rsto produce the.sopidof tf_linux64to find the game PIDsudo gdb -p <PID> -batch -ex "call dlopen(\"<path>\", 1)"to inject
so_inject.sh also supports uninject using dlclose with the handle returned by dlopen. The debug mode (--debug) additionally opens two gnome-terminal windows tailing the game's stdout/stderr file descriptors via /proc/<pid>/fd/1 and /proc/<pid>/fd/2, so you can see log:: output live.
cargo inject # build (debug) + inject
cargo inject --release # build (release) + inject
cargo inject --debug # build + inject + open debug terminalsRather than spawning a thread from DllMain (the Windows approach), on Linux we use ELF constructor/destructor arrays. In lib.rs:
#[used]
#[unsafe(link_section = ".init_array")]
static INIT: extern "C" fn() = { extern "C" fn init() { /* ... */ } init };
#[used]
#[unsafe(link_section = ".fini_array")]
static FINI: extern "C" fn() = { extern "C" fn fini() { /* ... */ } fini };.init_array— the dynamic linker calls function pointers in this section immediately afterdlopenmaps and relocates the library. This is where interfaces are resolved and hooks are installed..fini_array— called ondlclose. Used to restore original function pointers before the library is unloaded.#[used]prevents the compiler from dead-stripping the statics even though nothing in Rust references them directly.
The Source engine exposes its subsystems through a CreateInterface factory function exported from each shared library. Each library (client.so, engine.so, vgui2.so, vguimatsurface.so) exports this symbol, and you call it with a version string like "VEngineClient014" to get back an opaque *mut c_void pointing to a vtable-based object.
dlopen(lib path, RTLD_NOLOAD | RTLD_NOW) ← get handle to already-loaded lib
dlsym(handle, "CreateInterface") ← get factory fn pointer
factory("VEngineClient014", null) ← get interface pointer
RTLD_NOLOAD is important — it gets a handle to the library without loading it again (it's already in the game's address space).
Interfaces loaded at startup:
| Version String | Library | What it gives you |
|---|---|---|
VClient017 |
client.so |
Base client interface |
VEngineClient014 |
engine.so |
Engine: screen size, player info, in-game check |
VClientEntityList003 |
client.so |
Entity list access |
VGUI_Panel009 |
vgui2.so |
Panel names (for PaintTraverse hook) |
VGUI_Surface030 |
vguimatsurface.so |
Drawing primitives (lines, text, rects) |
VDebugOverlay003 |
engine.so |
World-to-screen projection |
EngineTraceClient003 |
engine.so |
Ray tracing (visibility checks) |
Some interfaces (like g_pClientMode and g_pGlobals) are not exported — they're internal globals. To find them, the code reads the compiled machine code of a known exported virtual function, extracts the RIP-relative effective address embedded in the instruction, and dereferences it:
// In HudProcessInput (vtable index 10 on VClient017):
// The function body is just: call [rip + offset_to_g_pClientMode]
let hud_process_input = *(before_add.add(10)) as usize;
let eaddr = ptr::read_unaligned((hud_process_input + 0x3) as *const u32);
let ip = hud_process_input + 0x7; // next instruction address
let client_mode = ptr::read_unaligned((ip + eaddr as usize) as *const *mut c_void);This technique (reading effective addresses from compiled instructions) works as long as the binary doesn't change. It's a common pattern in Source engine cheats.
For a thorough walkthrough of this technique, see the VTable Hooking guide.
Source engine objects (like IClientMode and IPanel) are C++ classes with vtables. A vtable is an array of function pointers stored in read-only memory. To hook a virtual function:
- Get the vtable pointer from the object (
*(obj as *mut *mut *mut c_void)) - Make the vtable page writable with
mprotect(..., PROT_READ | PROT_WRITE) - Save the original function pointer at the target index
- Write your hook function pointer at that index
- Restore the page to read-only with
mprotect(..., PROT_READ)
VTableHook (hooks/vtablehook.rs):
object ptr → vtable ptr → [fn0, fn1, ..., fnN, ...]
↑
overwrite this slot
Hooks installed:
IClientModevtable index 22 →CreateMove(runs every input frame; used for aimbot, bunnyhop, thirdperson)IPanelvtable index 42 →PaintTraverse(runs for each UI panel; used for ESP overlay)
SDL2 on Linux exports symbols through a JMP stub — the exported symbol is just a short jump to the real function pointer stored in writable memory. This lets SDL be updated without relinking consumers. The hook resolves through the stub to patch the underlying pointer directly:
SDL_PollEvent (exported symbol):
jmp [rip + 4] ← 2-byte opcode
<4-byte offset> ← signed offset relative to next instruction
↓
*ptr_to_actual_fn ← this is what we patch
let offset = ptr::read_unaligned((func as usize + 2) as *const i32);
let ptr_to_func = (func as usize + 6 + offset as usize) as *mut *mut c_void;
self.original = FnSig::from_ptr(*ptr_to_func, hook);
ptr::write(ptr_to_func, hook.as_ptr()?);Hooks installed:
SDL_PollEvent→hk_poll_event(captures keyboard/mouse input for the menu and keybinds)SDL_GL_SwapWindow→hk_swap_window(runs at end of each frame; used to render Nuklear menu and spectator list)
Two macros make reading game memory ergonomic:
Reads a function pointer from a vtable at a given index and transmutes it to the provided signature:
// Call virtual function at index 152: GetHealth() -> i32
let f = vfunc!(self.vtable, 152, extern "C" fn(*mut c_void) -> i32);
let health = f(self.this);Generates an accessor method that reads a value at a fixed byte offset from self.this:
offset_get!(pub fn team: Team, 0xDC);
// expands to:
pub fn team(&self) -> Team {
unsafe { core::ptr::read((self.this as *const u8).add(0xDC) as *const Team) }
}These offsets are found via reverse engineering (static analysis in IDA/Ghidra, or open-source TF2 SDK references like the Valve leak).
File: features/aimbot.rs | Hook: CreateMove
The aimbot selects the best target in the entity list and modifies the player's view_angles in the UserCmd before it is sent to the server.
Target selection:
- Iterate all entities in the entity list
- Skip: dormant, dead, same team, self
- For players: optionally aim for the head bone if
health > 50and the weapon supports headshots; otherwise aim for the torso (bone 1) - For buildings (Sentry, Dispenser, Teleporter): aim for the center of the AABB
- Calculate the aim angle to the target using
atan2on the 3D delta vector - Calculate the FOV from the current view angle to the target angle
- Reject targets outside the configured FOV cone
- Reject targets that fail a visibility ray trace
- Keep the closest-FOV target; priority category targets always beat normal targets
Headshot logic:
- Each player class has a different head bone ID (Engineer = 8, Demoman = 16, Sniper/Soldier = 5, others = 6)
- Headshots are suppressed if weapon spread > 0 (e.g., shotguns mid-spray)
Silent aim:
- When enabled,
hk_create_movereturns0instead of the real return value, which tells the engine not to use the modified angles for the local player's view — so the crosshair appears still while shots land on target
Key binding:
- Can be set to fire on attack button press, or to a dedicated held key (keyboard scancode or mouse button)
- Key capture uses an "editing mode" where the next input is recorded as the bind
Category filters:
- Ignore category: skip players tagged with this category entirely
- Priority category: always prefer players tagged with this category over untagged ones
File: features/esp.rs | Hook: PaintTraverse
ESP runs inside PaintTraverse when the panel is "FocusOverlayPanel" — a top-level panel that renders over the entire screen. It uses the VGUI Surface interface to draw directly.
Per-entity each frame:
- Get a screen-space bounding box by projecting all 8 corners of the entity's AABB through
DebugOverlay::ScreenPositionand taking the min/max of the resulting 2D points - Draw a 3-layer box: black outer border, colored middle, black inner border (for visibility against any background)
- Draw the player name above the box
- Draw a health bar: vertical bar on the right side for players, horizontal bar below for buildings. Color interpolates from green (full) through yellow to red (low)
Condition labels: Labels shown to the right of the box for special player states:
| Label | Meaning |
|---|---|
DISGUISED |
Spy is disguised |
TAUNTING |
Player is taunting (vulnerable) |
ZOOMED |
Sniper is scoped in |
INVISIBLE |
Spy is at >90% cloak (suppressed by Burning/Milk/Urine) |
MILKED |
Player has Mad Milk applied |
NO MG |
Enemy Soldier has >195 HP (likely has Escape Plan / Banner) |
BUTTER |
Enemy Soldier has ≤65 HP (easy market garden target) |
<category> |
Player is in a named custom category |
Color system:
- Can use TF2 team colors (RED team = red, BLU team = blue) or custom solid colors per faction
- Aimbot target overrides to orange
- Players in a custom category override to the category's color
FOV circle:
- When aimbot is enabled and Draw FOV is checked, a circle is drawn at the screen center showing the aimbot's field of view radius
File: features/movement.rs | Hook: CreateMove
Automates bunnyhopping by detecting when the player is airborne and removing the jump button from the UserCmd before it is processed. This prevents the momentary frame of ground contact from resetting velocity.
if !on_ground && was_jumping:
strip InJump from cmd.buttons
Hook: CreateMove + SDL_PollEvent
Sets a flag at memory offset 0x240C on the local player to switch the camera to third-person. Bound to a configurable key; pressing it toggles between first-person and third-person.
File: features/spectator_list.rs | Hook: SDL_GL_SwapWindow
Shows a Nuklear window in the top-right corner listing all players currently spectating you (or your observer target if you're dead). Each entry shows the observer mode:
| Mode | Color | Description |
|---|---|---|
| Firstperson | Red | Direct first-person spectate |
| Thirdperson | White | Chase cam |
| Freecam | White | Free-roaming camera |
| Roaming | White | Free-roaming (death cam variant) |
| Deathcam | White | Brief post-death cam |
| Fixed | White | Fixed camera position |
File: features/menu.rs | Hook: SDL_GL_SwapWindow
A Nuklear immediate-mode GUI rendered into a second OpenGL context. Toggle visibility with the Delete key. The menu is a movable, bordered window with six tabs:
- Master enable checkbox
- Silent aim toggle
- Use key / key binding capture
- Building aim toggle
- FOV slider (1–100 degrees)
- Draw FOV toggle
- Ignore category dropdown
- Priority category dropdown
- Master enable checkbox
- Per-faction entity ESP combos (enemy/friendly players, enemy/friendly buildings):
- Boxes, Names, Health bar
- Conditions multi-select combo
- Aimbot target highlight toggle
Tree-collapsed color pickers for:
- Box colors (enemy/friendly or team colors toggle)
- Name colors (enemy/friendly or team colors toggle)
- Building colors per type (Sentry, Dispenser, Teleporter)
- Condition label colors
- Player category colors (2×2 grid of 4 categories)
A scrollable table of all players currently in the server showing:
- Name
- GUID (Steam network ID)
- Category (click to cycle through None → Cat1 → Cat2 → Cat3 → Cat4 → None)
Category name fields and a "Save Names" button to rename the four categories.
- Bunnyhop toggle
- Spectator list toggle
- Thirdperson key binding
- Scrollable list of saved configs (selectable; clicking loads it)
- Save button (shown for selected config)
- Refresh button
- New config name field + Create button
File: player_db.rs
Players are identified by their GUID (a Steam network identifier returned by GetPlayerInfo). Each player can be assigned to one of four user-defined categories, which affects ESP color and aimbot behavior.
Persistence format (~/.tf_rs_players.cfg):
#cat1=Friendlies
#cat2=Enemies
#cat3=Regulars
#cat4=
STEAM_0:0:12345678|Cat 1
STEAM_0:1:87654321|Cat 2
Header lines (#catN=name) store custom category names. Body lines are GUID|CategoryName pairs. The file is read on load and written atomically on every change.
File: config/mod.rs
All feature settings are stored in a Config struct and persisted to ~/.{name}.tf_rs.cfg. Configs are plain-text key-value files:
bunnyhop: false
esp:
master: true
player_enemy:
boxes: true
names: true
health: true
aimbot:
master: false
fov: 15
...
Rather than pulling in serde, the config uses a custom proc macro that derives:
Display— recursive indented serialization to a text formatFromStr— recursive parser that handles both inline (key: value) and block (key:\n subkey: value) forms
This allows nested structs (like ESP → player_enemy → boxes) to round-trip through a human-readable file format without external dependencies.
The macro iterates struct fields at compile time and generates match arms for both serialization directions, supporting arbitrary nesting depth by delegating to inner types' own Display / FromStr impls.
The menu renders into a separate OpenGL context created alongside the game's existing context. This avoids corrupting the game's GL state.
Context management (nuklear/src/context.rs):
- On first call to
SDL_GL_SwapWindow, a new GL context is created and shared with the game's context - Each frame:
SDL_GL_MakeCurrent(window, new_ctx)→ render Nuklear →SDL_GL_MakeCurrent(window, og_ctx)→ let the game swap
Input handling:
SDL_PollEventis hooked to feed events into Nuklear before the game sees them- When the menu is open, events that Nuklear consumes are nulled out (
event.type_ = 0) so the game ignores them - The
is_draw_key_releasedcheck forDeletetogglesDO_DRAW(a static bool) and shows/hides the OS cursor viaSurface::SetCursorVisible
Rust wrapper (nuklear/):
The nuklear crate provides a safe Rust API over the raw C bindings in nuklear_sys. It wraps the Nuklear C library (included as a git submodule) compiled via bindgen. The wrapper exposes builder-style chaining:
nk.row_dynamic(30.0, 2)
.label("FOV", TextAlignment::LEFT)
.slider_int(1, &mut config.aimbot.fov, 100, 1);# Required packages (Ubuntu/Debian)
sudo apt install libsdl2-dev clang gdb libglew-devImportant: GLEW 2.1 is required. GLEW 2.2 does not work due to Steam Runtime library compatibility issues. On some distros you may need to install from source or use the Steam Runtime's GLEW.
The .cargo/config.toml defines a cargo inject alias. To run:
# Build (debug) and inject
cargo inject
# Build (release) and inject
cargo inject --release
# Build, inject, and open debug terminal windows (requires gnome-terminal)
cargo inject --debugThe injector finds the running TF2 process by name (tf_linux64), builds the library, then calls sudo gdb to inject it. Root is required because gdb needs ptrace permission on the game process.
# Build the library
cargo build -p tf_rs
# Find the PID
pidof tf_linux64
# Inject
sudo ./inject/so_inject.sh inject <PID> ./target/debug/libtf_rs.so
# Uninject (using the handle printed by dlopen)
sudo ./inject/so_inject.sh uninject <PID> <handle_address>| Crate / Library | Purpose |
|---|---|
anyhow |
Ergonomic error handling throughout |
log / env_logger |
Structured logging, visible via /proc/<pid>/fd/2 |
libc |
dlopen, dlsym, mprotect, sysconf, etc. |
once_cell |
Lazy<T> for deferred static initialization |
nuklear |
Local crate — safe Rust wrapper around Nuklear immediate GUI |
nuklear_sys |
Local crate — bindgen-generated C FFI for Nuklear + SDL2 bits |
| SDL2 | Game uses SDL2; we hook its PollEvent and SwapWindow |
| GLEW 2.1 | OpenGL extension loading for the Nuklear render context |
| GDB | Used by the injector to call dlopen in the game process |




