Aim Geometry and Target Selection
Connect vectors to yaw, pitch, angular error, target ranking, how animation poses a skeleton, target points, timing, and projectile motion.
Cheatsheet for Lesson 7.6
A one-page summary of “Aim Geometry and Target Selection” you can keep beside your notes. Plain Markdown.
Lesson 7.6 — Aim Geometry and Target Selection: cheatsheet
- Remember: Subtract positions before calculating angles; coincident points have no aiming direction to calculate.
- Also useful: Angles sit on a circle: crossing its numbered seam can be a short turn, just as midnight is close to one minute before midnight.
Key terms
- animation: An animation describes how some values change over time.
- keyframes: It stores values at a few moments, called keyframes, and the engine fills in the rest by interpolating between them (Lesson 4.7).
- animation event: An animation event is a marker at one moment in a clip that runs code, such as the footstep sound 0.3 s into the walk.
- bind pose: A model is rigged once in a resting posture called the bind pose — arms down, standing straight — and every vertex is authored relative to its bone as that bone sits in that one fixed posture.
Formulas and patterns
horizontal = √(delta_x² + delta_y²);yaw = atan2(delta_y, delta_x);pitch = atan2(delta_z, horizontal)
Review
- Q: Why use an explicit target score instead of selecting the first entity in memory? A: Distance, angle, visibility, team, and validity can be weighed by written rules
- Q: Your current yaw is
179°and the desired yaw is-179°. What signed angular change follows the shortest path? A: +2°
Break the problem into layers
Section titled “Break the problem into layers”An aim-assistance experiment contains three different problems:
- Snapshot — where are the camera and candidates right now?
- Decision — which candidate is valid and closest to the crosshair?
- Action — where and when should the verified view-angle update happen?
Keep these layers separate. Most of the interesting work belongs in safe, testable code.

Each arrow has to hold up its end of the deal, and the arrows before the maths are the ones that quietly break.
If the snapshot mixed two frames together, flawless trigonometry will aim confidently at where something used to be. If validation mistook a reused entity slot for a live one, the ranking step will happily pick a target that no longer exists. Neither failure looks like a maths error — the numbers are all beautifully computed — which is what makes them hard to find.
Maths cannot repair a bad snapshot. It can only give the wrong answer more decimal places.
Confirm the view angles
Section titled “Confirm the view angles”Use AssaultCube’s local debug commands and memory scanning to find yaw and pitch. Rotate only horizontally while searching for yaw, then only vertically for pitch.

Validate:
- one full turn changes yaw by the expected range;
- looking up and down changes pitch;
- standing still keeps both stable;
- values are finite;
- the sign and zero direction match your formulas.
Write the convention beside the values, for example:
yaw_degrees: increases clockwise when viewed from abovepitch_degrees: positive means looking downforward_at_zero: +Yup_axis: +ZThis prevents “fixes” such as unexplained +90 or sign changes from leaking into
generic math. Convert from the engine convention at one boundary, perform the
calculation in one documented convention, then convert back.
Read player snapshots
Section titled “Read player snapshots”Aim calculations need a stable set of facts about each player rather than a long-lived pointer into game memory. This record keeps identity, body and head positions, team, and health together. Its first filter rejects dead players, teammates, and invalid head coordinates before the angle math runs:
#[derive(Clone, Debug)]struct PlayerSnapshot { id: EntityId, position: Vec3, head: Vec3, team: u32, health: i32,}
impl PlayerSnapshot { fn is_valid_target(&self, local_team: u32) -> bool { self.health > 0 && self.team != local_team && valid_point(self.head) }}Find a verified player count or bounded list. Reject impossible counts before allocating or looping.
Exact AssaultCube 1.2.0.2 layout
Section titled “Exact AssaultCube 1.2.0.2 layout”In AssaultCube 1.2.0.2, the HUD can display a bot’s name when the crosshair selects it. Tracing that UI path and the code that walks player objects gives a way to check the entity-list globals. For this pinned build, the verified starting facts are:
local player pointer: 0x0050_9B74entity-list pointer: 0x0050_F4F8current player count: 0x0050_F500entity-list stride: 4 bytes (one 32-bit pointer)Enable dbgpos 1 and showstats 1, search for the displayed yaw as a float, and follow its writer back to [0x509B74]. Viewing the object as floats confirms these offsets:
#[repr(C)]struct AssaultCubePlayer { unknown_00: [u8; 0x04], x: f32, // +0x04 y: f32, // +0x08 z: f32, // +0x0C unknown_10: [u8; 0x30], yaw: f32, // +0x40 pitch: f32, // +0x44 unknown_48: [u8; 0x2F0], dead: i32, // +0x338: 0 alive, 1 dead}
const LOCAL_PLAYER: *const *mut AssaultCubePlayer = 0x0050_9B74 as *const *mut AssaultCubePlayer;const ENTITY_LIST: *const *const *mut AssaultCubePlayer = 0x0050_F4F8 as *const *const *mut AssaultCubePlayer;const PLAYER_COUNT: *const i32 = 0x0050_F500 as *const i32;First prove the layout by adding 1.0 to (*local).yaw once per update. Your view should spin smoothly. If it does not, stop—the build or layout is wrong.
Calculate desired angles
Section titled “Calculate desired angles”Take a camera position and one target point from those snapshots. Their
difference is the direction to aim; atan2 turns its horizontal components
into yaw, while the vertical component against horizontal distance gives
pitch. The function returns None when either point is invalid or both
points coincide, because then no direction exists:
#[derive(Clone, Copy, Debug)]struct Angles { yaw: f32, pitch: f32,}
fn angles_to(camera: Vec3, target: Vec3) -> Option<Angles> { if !valid_point(camera) || !valid_point(target) { return None; }
let delta = target.subtract(camera); let horizontal = delta.x.hypot(delta.y); if horizontal < f32::EPSILON && delta.z.abs() < f32::EPSILON { return None; }
Some(Angles { yaw: delta.y.atan2(delta.x).to_degrees(), pitch: delta.z.atan2(horizontal).to_degrees(), })}The math helpers avoid two tempting shortcuts:
The original helper divides by the forward axis to estimate yaw and pitch, then subtracts the current yaw directly. It loses quadrant information and can ask for a nearly full turn when the short turn crosses the angle wrap point. For this comparison the caller supplies validated, distinct positions and finite current angles; angles_to above shows the position checks.
fn aim_error(camera: Vec3, target: Vec3, current: Angles) -> Angles { let delta = target.subtract(camera); let yaw = (delta.y / delta.x).atan().to_degrees(); // Loses the quadrant. let pitch = (delta.z / delta.x).atan().to_degrees(); let yaw_error = yaw - current.yaw; // Can request the long turn. Angles { yaw: yaw_error, pitch: pitch - current.pitch }}Use atan2 to retain both axis signs, hypot for horizontal distance, and angle normalization to keep yaw error within one half-turn of zero. Preserve current yaw when the target is directly above or below, because that direction has no unique horizontal heading.
Improved code
fn aim_error(camera: Vec3, target: Vec3, current: Angles) -> Angles { let delta = target.subtract(camera); let horizontal = delta.x.hypot(delta.y); let yaw = if horizontal < f32::EPSILON { current.yaw // Directly above or below: horizontal heading is unconstrained. } else { delta.y.atan2(delta.x).to_degrees() }; let pitch = delta.z.atan2(horizontal).to_degrees(); let yaw_error = (yaw - current.yaw + 180.0) .rem_euclid(360.0) - 180.0; Angles { yaw: yaw_error, pitch: pitch - current.pitch }}Show the diff
fn aim_error(camera: Vec3, target: Vec3, current: Angles) -> Angles { let delta = target.subtract(camera); let yaw = (delta.y / delta.x).atan().to_degrees(); // Loses the quadrant. let horizontal = delta.x.hypot(delta.y); let yaw = if horizontal < f32::EPSILON { current.yaw // Directly above or below: horizontal heading is unconstrained. } else { delta.y.atan2(delta.x).to_degrees() }; let pitch = (delta.z / delta.x).atan().to_degrees(); let pitch = delta.z.atan2(horizontal).to_degrees(); let yaw_error = yaw - current.yaw; // Can request the long turn. let yaw_error = (yaw - current.yaw + 180.0) .rem_euclid(360.0) - 180.0; Angles { yaw: yaw_error, pitch: pitch - current.pitch }}Takeaway: preserve the coordinate information first, then normalize equivalent directions before comparing them.
Your target may need a yaw offset, inverted pitch, or different axes. Derive those adjustments from known directions.
Why the formulas have this shape
Section titled “Why the formulas have this shape”Project delta onto the horizontal plane first:
horizontal = √(delta_x² + delta_y²)yaw = atan2(delta_y, delta_x)pitch = atan2(delta_z, horizontal)Yaw asks for the direction of the horizontal projection. Pitch asks how far the full vector rises above that projection. Using total 3D distance as the second pitch argument would measure a different angle.
When horizontal = 0, the target is directly above or below. Pitch remains
defined, but yaw is mathematically unconstrained because every horizontal heading
points to the same vertical line. Preserve the current yaw in that case rather
than letting an arbitrary atan2(0, 0) result cause a snap.
Angle error can be measured without Euler angles
Section titled “Angle error can be measured without Euler angles”For a normalized current forward vector f and target direction d, the dot
product from Lesson 4.4 gives
the true angle between them with no yaw wraparound:
angular_error = acos(clamp(dot(f, d), -1, 1))A field-of-view cone does not need the angle itself. Compare the dot product with the cosine of the cone’s half-angle:
fn inside_cone(forward: Vec3, toward: Vec3, half_angle_degrees: f32) -> bool { let Some(forward) = normalized(forward) else { return false }; let Some(toward) = normalized(toward) else { return false }; let threshold = half_angle_degrees.to_radians().cos(); let dot = forward.x * toward.x + forward.y * toward.y + forward.z * toward.z; dot >= threshold}A smaller cone has a larger threshold, because the cosine falls as the angle
grows, and no candidate needs an acos call.
Measure angular difference
Section titled “Measure angular difference”Angles wrap around. The distance between 179° and -179° is 2°, not 358°.
fn shortest_angle_delta(current: f32, desired: f32) -> f32 { (desired - current + 180.0).rem_euclid(360.0) - 180.0}
fn angular_error(current: Angles, desired: Angles) -> f32 { let yaw = shortest_angle_delta(current.yaw, desired.yaw); let pitch = desired.pitch - current.pitch; yaw.hypot(pitch)}That one-line body looks like a trick until you read it as three separate moves. The goal is to take a raw difference, which can land anywhere in −360 to +360, and fold it into −180 to +180.
desired - current the raw difference, somewhere in -360..+360+ 180.0 shift it so the range you want becomes 0..360.rem_euclid(360.0) wrap it into 0..360- 180.0 shift back, giving -180..+180Run the earlier example through it. Turning from 179° to -179°:
-179 - 179 = -358-358 + 180 = -178(-178).rem_euclid(360) = 182182 - 180 = 2Two degrees, and the sign says which way to turn.
rem_euclid rather than % is the part worth remembering. Rust’s % takes
its sign from the left operand, so -178 % 360 is -178, and the formula
would hand back -358 — the long way round, which is the exact bug the
function exists to prevent. rem_euclid always returns a non-negative
remainder, which is what makes the wrap correct for negative inputs.
This interactive lab needs JavaScript. The lesson text around it still explains the same idea.
Select the smallest valid error
Section titled “Select the smallest valid error”The function receives copied player snapshots, the camera position and
current angles, and a maximum yaw/pitch score. It rejects invalid targets,
computes the desired view for each survivor, and returns the lowest-scoring
player with those desired angles. If none passes the limit, it returns
None:
fn best_target<'a>( players: &'a [PlayerSnapshot], camera: Vec3, current: Angles, local_team: u32, max_error: f32,) -> Option<(&'a PlayerSnapshot, Angles)> { players.iter() .filter(|player| player.is_valid_target(local_team)) .filter_map(|player| { let desired = angles_to(camera, player.head)?; let error = angular_error(current, desired); (error <= max_error).then_some((player, desired, error)) }) .min_by(|a, b| a.2.total_cmp(&b.2)) .map(|(player, angles, _)| (player, angles))}This is an iterator pipeline: filter invalid players, calculate candidates,
enforce the chosen yaw/pitch limit, then select the smallest score. The
angular_error helper here measures a practical two-axis camera turn with
hypot(yaw_delta, pitch_delta); it is not the exact three-dimensional angle
between two direction vectors. For a true circular field-of-view cone, use
the normalized-vector dot product from the previous section.
Be precise about the score. Nearest world distance, smallest angular error, and smallest screen-pixel distance answer different questions:
| Score | Favors | Important limitation |
|---|---|---|
| world distance | physically nearest target | may be far from crosshair |
| angular error | smallest camera rotation | ignores viewport aspect/FOV shape |
| pixel distance | nearest rendered marker | depends on current projection and resolution |
| weighted score | chosen trade-off | weights need units and testing |
Do not compare mixed units directly. If a score is
angle_degrees + distance_metres, its weights silently claim that one degree is
worth one metre. Normalize each term or state the conversion intentionally.
Tie-breaking must also be stable. If two scores differ only by floating-point noise, prefer the current target or a stable entity ID. Otherwise the choice can oscillate every frame.
How animation moves a character
Section titled “How animation moves a character”The next section aims at a character’s head, and a head is wherever the animation has put it this frame. An animation describes how some values change over time. The values can be a door’s angle, a button’s colour, the frame of a sprite, or the transforms of a whole skeleton.
Keyframes and interpolation. An animation does not store a value for every frame. It stores values at a few moments, called keyframes, and the engine fills in the rest by interpolating between them (Lesson 4.4). A door keyed at 0° at time 0.0 s and 90° at 1.0 s is at 90 × 0.25 = 22.5° a quarter of a second in. Add easing and the same moment gives 90 × 0.4375 = 39.375° with the ease-out curve from Lesson 4.4. Turns are blended as quaternions (Lesson 7.1). A sprite animation is the same idea with whole frames, picked from a sprite sheet by the clock (Lesson 7.2).
Skeletons and skinning. A 3D character is a mesh attached to a skeleton: a
hierarchy of bones, each with a transform relative to its parent bone (Lesson 7.1),
so turning the upper arm carries the forearm and hand. Every vertex lists up to four
bones and a weight for each, and the weights add up to 1. Animation changes the
bones’ transforms every frame, and the vertex shader moves each vertex by blending what
its bones did. To see the blend, use only movement. A vertex starts at (0, 1, 0) with
weight 0.75 on the upper arm and 0.25 on the forearm. In this pose the upper arm moves
its points by (2, 0, 0) and the forearm by (6, 0, 0):
vertex = 0.75 × (0 + 2, 1, 0) + 0.25 × (0 + 6, 1, 0) = (1.5, 0.75, 0) + (1.5, 0.25, 0) = (3, 1, 0)The vertex goes mostly where the upper arm goes and is pulled a little toward the forearm, which is why a bent elbow bends smoothly instead of tearing. Real bones use full transforms, not just a move, but the weighted blend is the same. A skeleton of 60 bones, with one 64-byte matrix each (Lesson 7.1), is 60 × 64 = 3,840 bytes of pose that is rebuilt every frame.
Choosing and blending clips. A character has many clips: idle, walk, run, jump.
An animation graph or state machine (Lesson 1.3) decides which clip plays and when
to switch. A switch is a cross-fade: for 0.2 s the pose is pose_A × (1 − w) + pose_B × w,
with the weight w rising from 0 to 1, so at 0.1 s it is an even half and half. A mask
restricts a clip to some bones, such as a wave that plays on the arm while the legs keep
walking. An animation event is a marker at one moment in a clip that runs code, such
as the footstep sound 0.3 s into the walk.
Morph targets. A face cannot be posed with bones alone. A morph target, or
blend shape, stores the changed positions of vertices for one expression as offsets from
the base mesh. A vertex at (0, 0, 0) with a smile offset of (0, 0.2, 0) and a smile
weight of 0.5 is drawn at (0, 0.1, 0). Many targets can be mixed at once.
For reading memory, this means a character’s pose is per-frame state, built from a clock value (seconds into the clip) and the bones’ matrices. It is not a fixed fact stored next to the character’s position.
Choose an aim point, not merely an entity origin
Section titled “Choose an aim point, not merely an entity origin”An entity’s position often refers to its feet, collision-cylinder center, or model pivot. A head position may come from a bone transform, tagged attachment point, or fixed offset. Animation changes bone positions each frame.
bone_world = entity_world × skeleton_pose × bone_bind_inverse × local_pointA model is rigged once in a resting posture called the bind pose — arms
down, standing straight — and every vertex is authored relative to its bone as
that bone sits in that one fixed posture. bone_bind_inverse undoes the
bone’s original resting transform, leaving the vertex expressed purely
relative to the bone, with the authoring pose divided back out. skeleton_pose
then reapplies the bone’s current, animated transform, and entity_world
places the whole rig in the level. Skip the bind-inverse step and every point
stays glued to the character’s resting pose no matter how the animation
actually moves.
The exact matrix order depends on convention. The important distinction is that a bone’s local coordinates are not world coordinates until the hierarchy has been accumulated. A constant z offset may work for one stance and fail while crouching, jumping, or using a different model.
Account for time only when the model requires it
Section titled “Account for time only when the model requires it”For an instantaneous hitscan query, aim at the current coherent target point. For
a projectile with constant speed s, target position p, target velocity v, and
shooter position o, solve for a positive time t:
|p + vt - o| = s t
(v·v - s²)t² + 2((p-o)·v)t + (p-o)·(p-o) = 0Get from one line to the other by squaring both sides — legal here because
both sides are distances, so neither can be negative. Write q = p - o for
the target’s position relative to the shooter, so the constraint reads
|q + vt| = st. Squaring a vector’s length turns it into a dot product with
itself (|x|² = x·x), which expands like ordinary multiplication:
(q + vt)·(q + vt) = (st)²q·q + 2t(q·v) + t²(v·v) = s²t²Move everything to one side and group by power of t and you have a standard
quadratic at² + bt + c = 0:
a = v·v - s²b = 2(q·v)c = q·qwhich is exactly the formula above with q written back out as p - o. The
quadratic formula then gives up to two candidate times. Choose the smallest
positive finite root, then aim at p + vt. If there is no
positive root, the constant-velocity target is not interceptable at that projectile
speed. Gravity, drag, network interpolation, and launch delay require a richer
model; do not hide them inside a guessed multiplier.
Test with synthetic data
Section titled “Test with synthetic data”You can prove the math without writing to a game:
#[test]fn wraparound_uses_shortest_turn() { let delta = shortest_angle_delta(179.0, -179.0); assert!((delta - 2.0).abs() < 0.001);}Add tests for targets above, below, behind, at the same position, and outside the allowed error.
Add convention tests with known basis directions. If the engine says forward is
+Y at 90°, encode that as a test. Also check NaN, infinity, a vertical target,
wrap boundaries on both sides, and deterministic tie-breaking.
Visualization before action
Section titled “Visualization before action”For the offline lab, first print the chosen ID and desired angles or draw a separate indicator. Confirm that the selected target matches what you see.

Write the working offline aimbot
Section titled “Write the working offline aimbot”The validated snapshots, angle calculations, and synthetic tests above are the core of this lesson. The pinned AssaultCube implementation below puts those calculations inside the DLL and hook boundary from Lesson 8.3. Keep the target check, worker lifetime, and verified-byte restoration procedure from that lesson around this game-specific code.
The in-process update resolves the list, chooses the nearest live entity, and writes the calculated angles:
/// # Safety/// Call only in AssaultCube 1.2.0.2 while a local bot match is active.unsafe fn update_aimbot() -> anyhow::Result<()> { // 🛡️ SAFETY: exact-build globals verified before the feature was enabled. let local = unsafe { LOCAL_PLAYER.read() }; anyhow::ensure!(!local.is_null(), "local player is unavailable");
// 📏 SAFETY: exact-build entity globals; count is bounded before iteration. let list = unsafe { ENTITY_LIST.read() }; let count = unsafe { PLAYER_COUNT.read() }; anyhow::ensure!(!list.is_null() && (1..=32).contains(&count), "bad entity list");
// 🔍 Copy the three coordinates while the pointer is known-good. The math // below then works on ordinary copied values instead of repeated raw reads. let local_position = unsafe { Vec3 { x: (*local).x, y: (*local).y, z: (*local).z, }};
let mut best: Option<(f32, Angles)> = None; for index in 0..count as usize { // ✅ SAFETY: `index` is inside the verified 32-entry list bound. let enemy = unsafe { list.add(index).read() }; if enemy.is_null() || enemy == local || unsafe { (*enemy).dead != 0 } { continue; }
let enemy_position = unsafe { Vec3 { x: (*enemy).x, y: (*enemy).y, z: (*enemy).z, }}; let Some(mut desired) = angles_to(local_position, enemy_position) else { // ⚠️ Coincident points have no useful direction; skip them instead // of allowing a divide-by-zero/NaN to reach the game's camera. continue; }; desired.yaw += 90.0; // AssaultCube's forward direction is 90° let delta = enemy_position.subtract(local_position); let distance = delta.x.hypot(delta.y);
// 🎯 Keep both the comparison score and its angles together so a later // candidate cannot accidentally update only half of the chosen target. if best.is_none_or(|(best_distance, _)| distance < best_distance) { best = Some((distance, desired)); } }
if let Some((_, desired)) = best { // 🎯 SAFETY: `local` is the verified live player object for this tick. unsafe { (*local).yaw = desired.yaw; (*local).pitch = desired.pitch; } } Ok(())}Run it every few milliseconds in the injected worker. In a local match with idle bots, the view should lock to the nearest live player and switch after that bot dies. The +90° correction is target-specific and comes from AssaultCube starting forward at 90 degrees.
Notice that this exact implementation ranks horizontal world distance, while
best_target above ranks angular error. That is an intentional contrast, not two names for the same target-ranking calculation. Replace the score only after deciding which behavior the
tool should implement.

The original aim demonstration shows the camera turning toward a selected player. Compare that visible turn with the position difference, desired yaw and pitch, and live angle update explained above.
Download Aim following a selected AssaultCube target videoDiagnose aim errors geometrically
Section titled “Diagnose aim errors geometrically”Aim errors, read geometrically
| Symptom | Likely assumption |
|---|---|
| constant 90° horizontal error | zero-forward convention |
| correct yaw, inverted pitch | pitch sign or screen/axis orientation |
| accurate nearby, trails moving targets | stale snapshots or missing motion model |
| selects targets behind camera | unwrapped angle or missing dot/FOV test |
| snaps when target is straight above | undefined yaw at zero horizontal distance |
| switches rapidly between two targets | unstable tie-breaking or noisy positions |
| points at feet while model animates | entity origin used instead of aim point/bone |
This is the complete path for the exact build: static globals → reversed player struct → target math → live yaw/pitch write.
The course DLL already runs that loop. Build and inject it into ac_client.exe, start a local bot match, and press F4 to toggle the aimbot:
cargo build --release --target i686-pc-windows-msvc.\target\i686-pc-windows-msvc\release\injector.exe ` ac_client.exe ` .\target\i686-pc-windows-msvc\release\gha_windows_labs.dllThe full compiled implementation is assaultcube_tools.rs. It bounds the player count to 32, rejects null pointers and non-finite coordinates, skips dead entities, chooses the closest live target, and performs the real volatile yaw/pitch writes. dll.rs owns the F4 state and stops the update loop before cleanup.
assaultcube_tools.rs
Show the codeHide the code
//! AssaultCube 1.2.0.2 aimbot and ESP, ported from the original course code.
#![cfg(target_arch = "x86")]
use std::{
arch::asm,
ptr,
sync::atomic::{AtomicI32, AtomicUsize, Ordering},
};
use anyhow::Result;
use super::{LocalPatch, near_call, near_jump};
const LOCAL_PLAYER_ROOT: usize = 0x0050_9B74;
const ENTITY_LIST_ROOT: usize = 0x0050_F4F8;
const PLAYER_COUNT: usize = 0x0050_F500;
const MAX_PLAYERS: usize = 32;
const POSITION_X: usize = 0x04;
const POSITION_Y: usize = 0x08;
const POSITION_Z: usize = 0x0C;
const VIEW_YAW: usize = 0x40;
const VIEW_PITCH: usize = 0x44;
const PLAYER_NAME: usize = 0x225;
const DEAD_FLAG: usize = 0x338;
const ESP_HOOK: usize = 0x0040_BE7E;
const ESP_RESUME: usize = 0x0040_BE83;
const PRINT_TEXT: usize = 0x0041_9880;
static ESP_X: [AtomicI32; MAX_PLAYERS] = [const { AtomicI32::new(-1) }; MAX_PLAYERS];
static ESP_Y: [AtomicI32; MAX_PLAYERS] = [const { AtomicI32::new(-1) }; MAX_PLAYERS];
static ESP_NAME: [AtomicUsize; MAX_PLAYERS] = [const { AtomicUsize::new(0) }; MAX_PLAYERS];
static EMPTY_TEXT: [u8; 1] = [0];
#[derive(Clone, Copy, Debug)]
struct Vec3 {
x: f32,
y: f32,
z: f32,
}
#[derive(Clone, Copy, Debug)]
struct Angles {
yaw: f32,
pitch: f32,
}
unsafe fn read_u32(address: usize) -> u32 {
// SAFETY: callers pass fields from the version-pinned AssaultCube profile.
unsafe { ptr::read_volatile(address as *const u32) }
}
unsafe fn read_f32(address: usize) -> f32 {
// SAFETY: callers pass aligned float fields from the verified player layout.
unsafe { ptr::read_volatile(address as *const f32) }
}
fn local_player() -> Option<usize> {
// SAFETY: this is the verified global player-pointer slot.
let player = unsafe { read_u32(LOCAL_PLAYER_ROOT) } as usize;
(player != 0).then_some(player)
}
fn player_count() -> usize {
// SAFETY: this global is a signed player count in the supported build.
let raw = unsafe { ptr::read_volatile(PLAYER_COUNT as *const i32) };
raw.clamp(0, MAX_PLAYERS as i32) as usize
}
fn entity_at(index: usize) -> Option<usize> {
if index >= player_count() {
return None;
}
// SAFETY: this global contains the start of the 32-bit entity pointer array.
let list = unsafe { read_u32(ENTITY_LIST_ROOT) } as usize;
if list == 0 {
return None;
}
let slot = list.checked_add(index.checked_mul(4)?)?;
// SAFETY: the clamped index selects one pointer-sized array slot.
let entity = unsafe { read_u32(slot) } as usize;
(entity != 0).then_some(entity)
}
fn position(entity: usize) -> Option<Vec3> {
// SAFETY: the entity was obtained from the verified list.
let result = unsafe {
Vec3 {
x: read_f32(entity + POSITION_X),
y: read_f32(entity + POSITION_Y),
z: read_f32(entity + POSITION_Z),
}
};
[result.x, result.y, result.z]
.iter()
.all(|value| value.is_finite() && value.abs() < 100_000.0)
.then_some(result)
}
fn view_angles(entity: usize) -> Option<Angles> {
// SAFETY: the verified player layout stores yaw and pitch at these offsets.
let result = unsafe {
Angles {
yaw: read_f32(entity + VIEW_YAW),
pitch: read_f32(entity + VIEW_PITCH),
}
};
(result.yaw.is_finite() && result.pitch.is_finite()).then_some(result)
}
fn is_alive(entity: usize) -> bool {
// SAFETY: the verified layout stores zero for a living player here.
unsafe { read_u32(entity + DEAD_FLAG) == 0 }
}
fn aim_from_to(from: Vec3, to: Vec3) -> (f32, Angles) {
let dx = to.x - from.x;
let dy = to.y - from.y;
let dz = to.z - from.z;
let horizontal = dx.hypot(dy);
let yaw = dy.atan2(dx).to_degrees() + 90.0;
let pitch = dz.atan2(horizontal).to_degrees();
(horizontal, Angles { yaw, pitch })
}
pub fn assaultcube_aim_once() -> Result<()> {
let Some(player) = local_player() else {
return Ok(());
};
let Some(player_position) = position(player) else {
return Ok(());
};
let mut closest: Option<(f32, Angles)> = None;
// Index zero is the local player in the course build.
for index in 1..player_count() {
let Some(enemy) = entity_at(index) else {
continue;
};
if !is_alive(enemy) {
continue;
}
let Some(enemy_position) = position(enemy) else {
continue;
};
let candidate = aim_from_to(player_position, enemy_position);
if closest.is_none_or(|current| candidate.0 < current.0) {
closest = Some(candidate);
}
}
if let Some((_, angles)) = closest {
// SAFETY: player is live and both values were calculated from finite data.
unsafe {
ptr::write_volatile((player + VIEW_YAW) as *mut f32, angles.yaw);
ptr::write_volatile((player + VIEW_PITCH) as *mut f32, angles.pitch);
}
}
Ok(())
}
fn shortest_angle_difference(from: f32, to: f32) -> f32 {
(from - to + 180.0).rem_euclid(360.0) - 180.0
}
pub fn assaultcube_update_esp() -> Result<()> {
let Some(player) = local_player() else {
return Ok(());
};
let Some(player_position) = position(player) else {
return Ok(());
};
let Some(player_view) = view_angles(player) else {
return Ok(());
};
let count = player_count();
for index in 1..MAX_PLAYERS {
if index < count {
let Some(enemy) = entity_at(index) else {
continue;
};
let Some(enemy_position) = position(enemy) else {
continue;
};
let (_, target) = aim_from_to(player_position, enemy_position);
let yaw_difference = shortest_angle_difference(player_view.yaw, target.yaw);
let pitch_difference = player_view.pitch - target.pitch;
let x = (1200.0 + yaw_difference * -30.0).round() as i32;
let y = (900.0 + pitch_difference * 25.0).round() as i32;
ESP_X[index].store(x, Ordering::Release);
ESP_Y[index].store(y, Ordering::Release);
ESP_NAME[index].store(enemy + PLAYER_NAME, Ordering::Release);
} else {
ESP_X[index].store(-1, Ordering::Release);
ESP_Y[index].store(-1, Ordering::Release);
ESP_NAME[index].store(0, Ordering::Release);
}
}
Ok(())
}
unsafe fn print_text(text: *const u8, x: u32, y: u32) {
// The game's function expects text in ECX and x/y as two stack arguments.
// The caller removes those eight argument bytes, matching the original code.
// SAFETY: PRINT_TEXT and the calling convention are verified for 1.2.0.2.
unsafe {
asm!(
"mov ecx, {text}",
"push {y}",
"push {x}",
"call {function}",
"add esp, 8",
text = in(reg) text,
x = in(reg) x,
y = in(reg) y,
function = in(reg) PRINT_TEXT,
clobber_abi("C"),
);
}
}
extern "C" fn draw_esp_names() {
for index in 1..player_count() {
let x = ESP_X[index].load(Ordering::Acquire);
let y = ESP_Y[index].load(Ordering::Acquire);
let name = ESP_NAME[index].load(Ordering::Acquire);
if !(0..=2400).contains(&x) || !(0..=1800).contains(&y) || name == 0 {
continue;
}
// SAFETY: the entity name pointer and internal print function belong to
// the current verified frame. The cave saved all general registers.
unsafe { print_text(name as *const u8, (x + 200) as u32, y as u32) };
}
}
#[unsafe(naked)]
unsafe extern "C" fn assaultcube_esp_cave() {
core::arch::naked_asm!(
// Replay the original print call but use an empty string so the normal
// center-screen text does not get printed twice.
"lea ecx, [{empty}]",
"call {print}",
"pushfd",
"pushad",
"call {draw}",
"popad",
"popfd",
"jmp {resume}",
empty = sym EMPTY_TEXT,
print = const PRINT_TEXT,
draw = sym draw_esp_names,
resume = const ESP_RESUME,
);
}
pub fn install_assaultcube_esp_hook() -> Result<LocalPatch> {
let expected = near_call(ESP_HOOK, PRINT_TEXT)?;
let cave = assaultcube_esp_cave as *const () as usize;
let replacement = near_jump(ESP_HOOK, cave)?;
LocalPatch::apply(ESP_HOOK, &expected, &replacement)
}
#[cfg(test)]
mod tests {
use super::shortest_angle_difference;
#[test]
fn angular_difference_wraps_at_the_seam() {
assert!((shortest_angle_difference(359.0, 1.0) + 2.0).abs() < f32::EPSILON);
assert!((shortest_angle_difference(1.0, 359.0) - 2.0).abs() < f32::EPSILON);
}
}dll.rs
Show the codeHide the code
//! DLL entry points and the small hotkey worker used by the course labs.
#![cfg(target_arch = "x86")]
use std::{
ffi::c_void,
path::Path,
sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
},
thread,
time::Duration,
};
use anyhow::{Context, Result};
use windows::Win32::{
Foundation::{HINSTANCE, HMODULE},
System::{LibraryLoader::DisableThreadLibraryCalls, SystemServices::DLL_PROCESS_ATTACH},
UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_END, VK_F1, VK_F2, VK_F3, VK_F4, VK_F5},
};
use windows::core::BOOL;
use super::{
LocalPatch, OPENGL_CHAMS, OPENGL_OFF, OPENGL_WALLHACK, TARGET_UNDER_CROSSHAIR,
assaultcube_aim_once, assaultcube_update_esp, enable_assaultcube_no_recoil,
enable_assaultcube_show_all_radar, enable_wesnoth_map_reveal, install_assaultcube_esp_hook,
install_assaultcube_trigger_hook, install_urban_terror_memory_wallhook,
install_urban_terror_opengl_hook, install_wesnoth_stat_hook, install_wesnoth_terrain_hook,
run_flare_farming_bot, run_wyrmsun_macro, send_left_mouse, set_urban_terror_opengl_mode,
write_wesnoth_gold,
};
static STARTED: OnceLock<()> = OnceLock::new();
static STOP: AtomicBool = AtomicBool::new(false);
fn pressed_once(virtual_key: i32) -> bool {
// SAFETY: GetAsyncKeyState accepts any virtual-key integer and returns a
// value. The low bit means the key was pressed since the previous query.
unsafe { GetAsyncKeyState(virtual_key) & 1 != 0 }
}
fn toggle(
slot: &mut Option<LocalPatch>,
install: impl FnOnce() -> Result<LocalPatch>,
) -> Result<()> {
if let Some(mut patch) = slot.take() {
patch.restore()?;
} else {
*slot = Some(install()?);
}
Ok(())
}
fn current_process_name() -> Result<String> {
let executable = std::env::current_exe().context("Windows did not report the game path")?;
let name = Path::new(&executable)
.file_name()
.context("the game path has no file name")?
.to_string_lossy()
.into_owned();
Ok(name)
}
fn run_assaultcube() -> Result<()> {
let mut trigger = None;
let mut no_recoil = None;
let mut radar = None;
let mut esp = None;
let mut aimbot_enabled = false;
let mut mouse_down = false;
while !STOP.load(Ordering::Acquire) {
if pressed_once(VK_F1.0.into()) {
if trigger.is_some() && mouse_down {
send_left_mouse(false)?;
mouse_down = false;
}
toggle(&mut trigger, install_assaultcube_trigger_hook)?;
if trigger.is_none() {
TARGET_UNDER_CROSSHAIR.store(false, Ordering::Release);
}
}
if pressed_once(VK_F2.0.into()) {
toggle(&mut no_recoil, enable_assaultcube_no_recoil)?;
}
if pressed_once(VK_F3.0.into()) {
toggle(&mut radar, enable_assaultcube_show_all_radar)?;
}
if pressed_once(VK_F4.0.into()) {
aimbot_enabled = !aimbot_enabled;
}
if pressed_once(VK_F5.0.into()) {
toggle(&mut esp, install_assaultcube_esp_hook)?;
}
if aimbot_enabled {
assaultcube_aim_once()?;
}
if esp.is_some() {
assaultcube_update_esp()?;
}
let wants_mouse_down = trigger.is_some() && TARGET_UNDER_CROSSHAIR.load(Ordering::Acquire);
if wants_mouse_down != mouse_down {
send_left_mouse(wants_mouse_down)?;
mouse_down = wants_mouse_down;
}
if pressed_once(VK_END.0.into()) {
break;
}
thread::sleep(Duration::from_millis(8));
}
if mouse_down {
send_left_mouse(false)?;
}
// Dropping these values restores the original instructions in reverse
// lexical order after the worker has stopped using them.
drop(esp);
drop(radar);
drop(no_recoil);
drop(trigger);
Ok(())
}
fn run_wesnoth() -> Result<()> {
write_wesnoth_gold(999)?;
let mut terrain = None;
let mut stat = None;
let mut map = None;
while !STOP.load(Ordering::Acquire) {
if pressed_once(VK_F1.0.into()) {
toggle(&mut terrain, install_wesnoth_terrain_hook)?;
}
if pressed_once(VK_F2.0.into()) {
toggle(&mut stat, install_wesnoth_stat_hook)?;
}
if pressed_once(VK_F3.0.into()) {
toggle(&mut map, enable_wesnoth_map_reveal)?;
}
if pressed_once(VK_END.0.into()) {
break;
}
thread::sleep(Duration::from_millis(20));
}
drop(map);
drop(stat);
drop(terrain);
Ok(())
}
fn run_urban_terror() -> Result<()> {
let mut memory_wallhook = None;
let mut opengl_hook = None;
let mut opengl_mode = OPENGL_OFF;
while !STOP.load(Ordering::Acquire) {
if pressed_once(VK_F1.0.into()) {
toggle(&mut memory_wallhook, install_urban_terror_memory_wallhook)?;
}
if pressed_once(VK_F2.0.into()) {
if opengl_mode == OPENGL_WALLHACK {
set_urban_terror_opengl_mode(OPENGL_OFF)?;
drop(opengl_hook.take());
opengl_mode = OPENGL_OFF;
} else {
if opengl_hook.is_none() {
opengl_hook = Some(install_urban_terror_opengl_hook(OPENGL_WALLHACK)?);
}
set_urban_terror_opengl_mode(OPENGL_WALLHACK)?;
opengl_mode = OPENGL_WALLHACK;
}
}
if pressed_once(VK_F3.0.into()) {
if opengl_mode == OPENGL_CHAMS {
set_urban_terror_opengl_mode(OPENGL_OFF)?;
drop(opengl_hook.take());
opengl_mode = OPENGL_OFF;
} else {
if opengl_hook.is_none() {
opengl_hook = Some(install_urban_terror_opengl_hook(OPENGL_CHAMS)?);
}
set_urban_terror_opengl_mode(OPENGL_CHAMS)?;
opengl_mode = OPENGL_CHAMS;
}
}
if pressed_once(VK_END.0.into()) {
break;
}
thread::sleep(Duration::from_millis(8));
}
set_urban_terror_opengl_mode(OPENGL_OFF)?;
drop(opengl_hook);
drop(memory_wallhook);
Ok(())
}
fn run_lab() -> Result<()> {
let process = current_process_name()?.to_ascii_lowercase();
match process.as_str() {
"wesnoth.exe" => run_wesnoth(),
"ac_client.exe" => run_assaultcube(),
"quake3-urt.exe" => run_urban_terror(),
"flare.exe" => run_flare_farming_bot(&STOP),
"wyrmsun.exe" => run_wyrmsun_macro(&STOP),
_ => anyhow::bail!("{process:?} is not a configured course target"),
}
}
/// Windows calls this under the loader lock. Do only the one documented,
/// loader-safe optimization here; the injector calls `gha_start` afterward.
#[unsafe(no_mangle)]
pub extern "system" fn DllMain(instance: HINSTANCE, reason: u32, _reserved: *mut c_void) -> BOOL {
if reason == DLL_PROCESS_ATTACH {
// SAFETY: `instance` is the module Windows is currently attaching.
let _ = unsafe { DisableThreadLibraryCalls(HMODULE(instance.0)) };
}
BOOL::from(true)
}
/// Exported with the Windows thread-procedure signature so the injector can
/// call it safely after LoadLibraryW has returned and released the loader lock.
#[unsafe(no_mangle)]
pub extern "system" fn gha_start(_argument: *mut c_void) -> u32 {
if STARTED.set(()).is_err() {
return 1; // already running
}
STOP.store(false, Ordering::Release);
let started = thread::Builder::new()
.name("gha-tool-worker".into())
.spawn(|| {
if let Err(error) = run_lab() {
eprintln!("game lab stopped: {error:#}");
}
});
if started.is_err() {
return 2;
}
0
}
#[unsafe(no_mangle)]
pub extern "system" fn gha_stop(_argument: *mut c_void) -> u32 {
STOP.store(true, Ordering::Release);
0
}Choose a target deliberately
Why use an explicit target score instead of selecting the first entity in memory?
An explicit score makes selection predictable, testable, and independent of arbitrary container order.
Finished Lesson 7.6?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.