Skip to content
Lesson 7.9Camera Geometry and Rendering APIs

World-to-Screen Projection and Overlays

Follow a 3D point through world, view, clip, normalized, and screen coordinates, with the field of view, aspect ratio, and each camera's viewport, to place stable overlays.

Lesson 7.9
Read or listenPrint chapter or book
About 44 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 7.9Key ideas, reference and questions

A one-page summary of “World-to-Screen Projection and Overlays” you can keep beside your notes. Plain Markdown.

Lesson 7.9 — World-to-Screen Projection and Overlays: cheatsheet

  • Remember: Perspective division makes the same world-sized object occupy fewer screen pixels as its depth increases.
  • Also useful: Projected depth belongs to the camera’s depth convention; use world or view-space distance when displaying a range in metres.

Key terms

  • w: If w is zero, the division is undefined.
  • screen_y: Since screen_y is proportional to view_y / view_depth, the scale factor that sends tan(fov_y / 2) to 1 is its reciprocal: y_scale = 1 / tan(fov_y / 2).
  • aspect: At the same depth d, the frustum's half-width must be aspect times its half-height, because aspect is defined as width / height.
  • x_scale: define f = 1 / tan(fov_y / 2), the y_scale from the previous section, so the matrix's first entry, f/aspect, is x_scale.
  • ndc_z: ndc_z is produced by the projection and perspective divide.

Formulas and patterns

  • ndc_x = clip_x / clip_w; ndc_y = clip_y / clip_w

Review

  • Q: Why check clip-space w before the perspective divide? A: A zero or negative w can mean the point is on or behind the camera plane
  • Q: The transform produces a finite clip_w of −0.4. What should the observer do before the perspective divide? A: Reject the point as behind the usable camera half-space
Download cheatsheet (.md)

World-to-screen uses the view-projection transform

Section titled “World-to-screen uses the view-projection transform”

An ESP-style observer needs to answer:

Where would this 3D point appear on the 2D screen?

The reliable answer uses the game’s view-projection matrix. Hand-tuned angle formulas can teach geometry, but a matrix handles camera rotation, field of view, aspect ratio, and perspective together.

A simple viewport diagram

A simple viewport diagram.

The matrix is not magic screen-coordinate data. It is a compact composition of specific coordinate changes. If its convention, snapshot time, or viewport does not match the rendered frame, the resulting pixels can be precise arithmetic and still describe the wrong image.

“World to screen” is not one mysterious jump. It is a pipeline of smaller changes, and every stage answers a different question:

world space
where the entity exists in the game world
↓ view transform
camera space
where the entity is relative to the camera
↓ projection transform
clip space (x, y, z, w)
a perspective-aware position before division
↓ divide x, y, and z by w
normalized device coordinates
a small standard cube used for clipping
↓ viewport transform
screen pixels
where the overlay may draw
model
view
projection
clip, then divide by w
viewport
Local vertex
(x,y,z,1)
World
Camera
Clip
(x,y,z,w)
NDC
Pixels

This tour chooses a world point (2, 1, -4) and a camera at (1, 0, 0) looking along negative Z. Its right and up directions match the world axes. Subtracting the camera position gives (1, 1, -4). Choose an 800-by-800 viewport so the aspect ratio is one. A 90-degree vertical field of view gives 1 / tan(45°) = 1. Choose near and far distances of one and nine; the depth formula below then gives (9 + 1) / (1 - 9) = -1.25 and 2 × 9 × 1 / (1 - 9) = -2.25. These settings make every conversion visible.

Watch it happenA camera ray places a marker on the viewport

The inverse camera pose makes the point camera-relative. Its ray crosses a projection plane; dividing by depth gives normalized coordinates. The viewport maps those coordinates to pixels. Doubling depth moves the marker halfway toward the centre, with the first marker retained as a small grey reference.

Read the steps
  1. Choose a world point (2,1,−4), a camera at (1,0,0), and an 800 by 800 square viewport. The camera looks down negative Z; its right and up axes match the world.
  2. The inverse camera pose subtracts (1,0,0). The point becomes (1,1,−4) relative to the camera. The whole top view shifts left while the point–camera relationship stays the same.
  3. A 90-degree vertical view gives f=1/tan(45°)=1. Square aspect is 800/800=1. Thus clip X and Y stay 1, while W=−view Z=4. Near 1 and far 9 give clip Z=(−1.25×−4)−2.25=2.75.
  4. The anchor passes clipping: W is positive, X=1 and Y=1 lie between −4 and 4, and Z=2.75 also lies between −4 and 4. This accepted point may continue to the viewport.
  5. Divide by W=4. Normalized X and Y are 1/4=0.25; normalized Z is 2.75/4=0.6875. The ray intersects the projection plane one quarter of a horizontal unit from its centre.
  6. Map normalized coordinates into pixels. X=(0.25+1)×800/2=500. Screen Y points down, so Y=(1−0.25)×800/2=300. The marker appears right of and above the centre.
  7. Double the depth from 4 to 8 without changing relative X or Y. W becomes 8, so both normalized offsets halve to 1/8=0.125. The pixel moves to (450,350), closer to the centre.

Declared convention: column vectors, negative Z forward, OpenGL depth. The muted dot is the first valid marker; the coloured dot is the farther point.

Naming the stages makes bugs easier to locate. A marker that rotates the wrong way usually points to the view transform. A marker that stretches after a resolution change points to the viewport or aspect ratio. A point behind the camera that appears mirrored points to the perspective divide or clipping.

The w value is not another world direction; Why perspective uses w shows how it carries depth. Reject unusable w values before dividing, then reject points outside the target API’s depth interval. OpenGL commonly uses -1..=1 after projection; Direct3D commonly uses 0..=1. The matrix and convention must come from the same game pipeline.

The view matrix is the inverse camera pose

Section titled “The view matrix is the inverse camera pose”

A camera pose says where the camera exists in world space. Rendering needs the opposite question: where does the world appear relative to the camera? Therefore the view matrix is the inverse of the camera’s world transform.

For an orthonormal camera basis, inverse rotation is its transpose. The translation is not simply -camera_position; it is the negative position expressed along the camera basis. In dot-product form, that is the view transform from Lesson 7.1: subtract the camera position, then take the dot product of the result with each camera axis.

If positions track correctly until the camera rotates, the translation may be right while the basis order, handedness, or inverse operation is wrong.

Far-away objects look smaller. After the matrix transformation, perspective division divides the transformed x, y, and z by w:

ndc_x = clip_x / clip_w
ndc_y = clip_y / clip_w

This creates perspective because the projection matrix is built so that the resulting w carries the point’s distance from the camera: dividing by w divides by depth. Ignoring signs and API details, for a symmetric perspective camera:

screen_x ∝ view_x / view_depth
screen_y ∝ view_y / view_depth

Double the depth while keeping the sideways offset unchanged and the point moves half as far from the screen center, so an object twice as far away covers half as much screen. A perspective matrix stores this division in w so the GPU can delay it until after clipping.

If w is zero, the division is undefined. In the convention used in this lesson, where a point in front of the camera has a positive w, a negative w puts the point behind the camera — and the division still yields a perfectly ordinary-looking coordinate, with both signs flipped. This is the classic overlay bug: an enemy standing directly behind the player gets drawn on screen in front, mirrored to the opposite side, with the box sliding the wrong way as they move. Nothing crashes and no number looks obviously wrong, so world-to-screen code must skip any point whose w is at or below a small positive threshold before producing pixels, as the clip_w test in Transform a point does.

What field of view and aspect ratio change

Section titled “What field of view and aspect ratio change”

The vertical field of view fov_y determines how much of the world fits from top to bottom. The aspect ratio is width / height. A common perspective scale is:

y_scale = 1 / tan(fov_y / 2)
x_scale = y_scale / aspect

Here is where that comes from. Picture the frustum at some depth d straight ahead of the camera. Half the vertical field of view is the angle from the center line up to the top edge of what the camera sees, so — for a right triangle with that angle and an adjacent side of length d — the frustum’s half-height at that depth is d × tan(fov_y / 2). A point sitting exactly on that top edge has view_y = d × tan(fov_y / 2), and it needs to land at the very top of the screen, NDC y = 1. Since screen_y is proportional to view_y / view_depth, the scale factor that sends tan(fov_y / 2) to 1 is its reciprocal: y_scale = 1 / tan(fov_y / 2).

Concrete numbers make the zoom effect visible. At fov_y = 90°, half the angle is 45° and tan(45°) = 1, so y_scale = 1. Narrow the field of view to 60°: half the angle is 30°, tan(30°) ≈ 0.577, and y_scale ≈ 1.732 — a bigger multiplier, so the same view_y / view_depth now produces a larger NDC value and the scene looks zoomed in. A smaller field of view makes tan(fov_y / 2) smaller, so the scale grows and the view zooms in.

One practical trap sits in that formula. Trigonometric functions take radians, not degrees. Passing 90.0 where 1.5708 was expected produces a scale that is wrong by a wide margin, and the symptom is a scene that looks nothing like a camera view rather than an obviously wrong number. Convert at the boundary and put the unit in the variable name.

x_scale reuses that number instead of deriving a separate one. At the same depth d, the frustum’s half-width must be aspect times its half-height, because aspect is defined as width / height. Dividing y_scale by aspect shrinks the horizontal scale by exactly that factor, so the wider half-width still maps to NDC x = 1. Skip that division and a widescreen monitor squashes the image horizontally instead of showing more of the scene.

Conventions must be attached to formulas. Under these assumptions:

  • column vectors;
  • right-handed camera space looking down -Z;
  • vertical field of view fov_y;
  • aspect ratio width / height;
  • OpenGL NDC depth -1..1;
  • positive near and far distances with 0 < near < far;

define f = 1 / tan(fov_y / 2), the y_scale from the previous section, so the matrix’s first entry, f/aspect, is x_scale. One projection matrix is:

[ f/aspect 0 0 0 ]
[ 0 f 0 0 ]
[ 0 0 (far+near)/(near-far) 2far·near/(near-far) ]
[ 0 0 -1 0 ]

Multiplying a view-space point gives clip_w = -view_z, so points in front have positive w. This exact matrix is not portable to a left-handed or Direct3D depth convention. Its purpose is to show where assumptions live.

Clipping happens before perspective division

Section titled “Clipping happens before perspective division”

In homogeneous clip space, the standard OpenGL visible volume satisfies:

-w ≤ x ≤ w
-w ≤ y ≤ w
-w ≤ z ≤ w

For a Direct3D-style depth range, z is commonly constrained by 0 ≤ z ≤ w while x and y use the same bounds. A triangle crossing a plane is clipped into a smaller polygon; it is not discarded merely because one vertex is outside.

Point labels are simpler: if their single anchor lies outside, reject or clamp the label according to the chosen off-screen-label behavior. Do not use a point-label boundary check to reason about whether an entire triangle should render.

The playground below uses one declared convention so every sign and axis has a meaning. It deliberately treats forward as positive +Z, unlike the -Z projection example above; this makes the depth control read naturally and shows why formulas cannot be separated from their coordinate convention. Move the point sideways, vertically, and through depth; then change the vertical field of view. Watch how camera-space coordinates become NDC and finally pixels.

Interactive projection model

Move one camera-space point through perspective

Convention: the camera looks along +Z, +X points right, +Y points up, and the viewport is 1280 × 720.

Top view of the camera frustum
Camera frustum viewed from aboveThe camera sits at the left. Two rays widen with depth, and a movable point can be inside or outside those rays.camera+Zpoint
Viewport after the perspective divide
Projected point on a sixteen by nine viewportA movable point is plotted on a rectangular screen after conversion from normalized device coordinates.(0, 0)(1280, 720)(—, —)
Inside the view

The projection routine needs three separate pieces of data: sixteen matrix values, a screen position for successful projections, and the renderer’s accepted depth range. Naming them here keeps a Direct3D depth convention from accidentally being applied to an OpenGL matrix:

#[derive(Clone, Copy, Debug)]
struct Mat4 {
values: [[f32; 4]; 4],
}
#[derive(Clone, Copy, Debug)]
struct ScreenPoint {
x: f32,
y: f32,
depth: f32,
}
#[derive(Clone, Copy, Debug)]
enum DepthConvention {
OpenGl,
Direct3D,
}
impl DepthConvention {
fn contains(self, z: f32) -> bool {
match self {
Self::OpenGl => (-1.0..=1.0).contains(&z),
Self::Direct3D => (0.0..=1.0).contains(&z),
}
}
}

First determine whether the target stores matrices row-major or column-major. Test with a known point while the camera is stationary.

There are three independent questions:

  1. are consecutive values a row or a column (storage layout)?
  2. does the math use row or column vectors (multiplication convention)?
  3. is the candidate already view × projection, projection × view, or one component only (composition)?

A wrong answer to the first and a wrong answer to the second can cancel, and neither the identity matrix nor a still camera reveals it. Validate identity, translation, and a known 90-degree rotation together rather than repeatedly transposing until one screenshot looks plausible.

Optional reference: how two layout mistakes cancel

Those three interact in a way that actively hides mistakes. Transposing a matrix swaps its rows and columns, and switching between row-vector and column-vector multiplication has precisely the same effect on the arithmetic. Two wrong answers therefore cancel, and the transform appears to work — for the wrong reasons:

storage layout read wrong -> effectively transposes the matrix
multiplication convention wrong -> transposes it straight back
on screen -> correct, and still wrong

What makes this hard to catch is that the easiest test cannot detect it. The identity matrix is symmetric, so transposing it changes nothing and it passes under every combination of the three choices. A camera sitting still is nearly as forgiving. A translation matrix and a 90-degree rotation matrix are not symmetric, so under a cancelled pair of mistakes they are the first things to break.

One row-major form looks like:

fn world_to_screen(
point: Vec3,
matrix: Mat4,
width: f32,
height: f32,
depth_convention: DepthConvention,
) -> Option<ScreenPoint> {
if !valid_point(point) || width <= 0.0 || height <= 0.0 {
return None;
}
let m = matrix.values;
let clip_x = point.x * m[0][0] + point.y * m[0][1]
+ point.z * m[0][2] + m[0][3];
let clip_y = point.x * m[1][0] + point.y * m[1][1]
+ point.z * m[1][2] + m[1][3];
let clip_z = point.x * m[2][0] + point.y * m[2][1]
+ point.z * m[2][2] + m[2][3];
let clip_w = point.x * m[3][0] + point.y * m[3][1]
+ point.z * m[3][2] + m[3][3];
if !clip_w.is_finite() || clip_w <= 0.001 {
return None; // behind the camera or too close to project
}
let ndc_x = clip_x / clip_w;
let ndc_y = clip_y / clip_w;
let ndc_z = clip_z / clip_w;
if !ndc_x.is_finite() || !ndc_y.is_finite() || !ndc_z.is_finite() {
return None;
}
if !depth_convention.contains(ndc_z) {
return None; // closer than the near plane or beyond the far plane
}
Some(ScreenPoint {
// 🗺️ Convert normalized coordinates from -1..1 into screen pixels.
// `midpoint` expresses the halfway step without overflowing the sum.
x: ndc_x.midpoint(1.0) * width,
y: (-ndc_y).midpoint(1.0) * height,
depth: ndc_z,
})
}

The positive-w front-facing test belongs to the projection convention described above. Some pipelines use a different sign. Determine the front-facing half-space with a point known to be directly ahead. The small epsilon avoids enormous coordinates near the camera plane, but it is a policy threshold in clip-space units, not a universal constant.

The midpoint calls implement:

screen_x = viewport_x + (ndc_x + 1) × viewport_width / 2
screen_y = viewport_y + (1 - ndc_y) × viewport_height / 2

The y flip assumes an upper-left pixel origin. OpenGL’s traditional viewport origin is lower-left, and UI/window systems commonly use upper-left. Confirm which layer the overlay draws into.

A camera is an object with a position and a turn like any other, plus a projection and a few settings: which surface it draws into (the window, or an off-screen image), which rectangle of that surface (its viewport), and when it draws compared with the other cameras (its order). A split screen, a minimap, a rear-view mirror, and a security monitor inside the game each use another camera. A camera that draws into an off-screen image makes a texture that a material can then show on a mirror or a monitor. Every formula in this lesson works for each camera separately, using that camera’s viewport rather than the whole window.

ExampleA 1920 × 1080 window with two players and a minimap

Camera Viewport (x, y, width, height) Aspect Centre of its picture
player 1 (0, 0, 960, 1080) 960 ÷ 1080 = 0.889 (480, 540)
player 2 (960, 0, 960, 1080) 960 ÷ 1080 = 0.889 (960 + 480, 540) = (1440, 540)
minimap (1664, 0, 256, 256) 256 ÷ 256 = 1 (1664 + 128, 128) = (1792, 128)

The window’s own aspect is 1920 ÷ 1080 = 1.778. Give player 1’s camera that value instead of 0.889, and its horizontal scale, y_scale ÷ aspect, comes out 0.889 ÷ 1.778 = 0.5 times what it should be, so the picture is squeezed to half its width. And a world point that projects to the middle of player 2’s picture belongs at pixel x = 1440, not at 480.

The minimap’s viewport lies inside player 2’s, so the minimap must draw later. The camera with the lower order draws first, and a later camera can paint over what is already there instead of clearing it, which is how a minimap sits on top of the main picture. Which objects each camera draws is decided by the layer filters, culling, and visibility tests in Lesson 7.2.

In memory, a game with several cameras can hold several view-projection matrices. The one that belongs to the picture you draw over is the one that changes when you turn that player’s view, and its viewport is the rectangle to use in the pixel conversion above. world_to_screen counts pixels from the top left of the viewport whose width and height it receives, so add the viewport’s own origin to get window pixels: for player 2, the centre of the picture is (480, 540) inside the viewport and (960 + 480, 540) in the window.

Depth after projection is not world distance

Section titled “Depth after projection is not world distance”

ndc_z is produced by the projection and perspective divide. For a perspective camera it is nonlinear with view-space distance. Two points separated by one metre near the camera can have a much larger depth-buffer difference than two points one metre apart near the far plane.

Use Euclidean or view-space distance for range labels. Use projected depth to test the clip range or compare against a compatible depth buffer. Do not display ndc_z as metres.

If all markers move incorrectly, the likely problem is matrix layout or coordinate conventions—not a random “scale constant.”

A view matrix changes when the camera moves or rotates. A projection matrix changes with field of view, aspect ratio, or resolution.

Search for a group of 16 floats and compare:

  • camera still versus rotating;
  • same camera at two resolutions;
  • one known world point projected through a candidate.

A pattern scanner may later locate the code or global pointer that produces the matrix.

Candidate-matrix invariants help reject false positives:

  • all 16 values are finite;
  • the camera-dependent portion changes smoothly with camera movement;
  • the projection scale changes predictably with field of view/aspect ratio;
  • a point directly ahead projects near the center;
  • farther points with the same direction approach the same screen coordinate;
  • multiplying by an identity test matrix leaves a test point unchanged.

Camera, matrix, players, and viewport size should come from roughly the same frame. If the game updates them while an external tool is reading, markers may jitter.

Use one bounded read pass:

struct FrameSnapshot {
matrix: Mat4,
viewport: (f32, f32),
depth_convention: DepthConvention,
players: Vec<PlayerSnapshot>,
}

Validate every float and cap player count.

“Roughly the same frame” is measurable. Attach a sequence number before and after the read when the target exposes one, and retry if it changed. Without a sequence, read the matrix twice around the entity batch and reject the sample if it changed materially. This does not create atomicity, but it detects many torn snapshots.

A correct projection may still return None for a point behind the camera or outside the view. Build labels only from valid player snapshots whose positions survive world_to_screen; the result is a list of text and screen points that the overlay can draw without touching game memory again:

#[derive(Debug)]
struct Label {
text: String,
screen: ScreenPoint,
}
fn build_labels(frame: &FrameSnapshot, local_team: u32) -> Vec<Label> {
let (width, height) = frame.viewport;
frame.players.iter()
.filter(|player| player.is_valid_target(local_team))
.filter_map(|player| {
let screen = world_to_screen(
player.head,
frame.matrix,
width,
height,
frame.depth_convention,
)?;
let on_screen = (0.0..=width).contains(&screen.x)
&& (0.0..=height).contains(&screen.y);
on_screen.then(|| Label {
text: format!("#{}", player.id.0),
screen,
})
})
.collect()
}

Use sanitized copied text for names. Never follow a remote string pointer indefinitely; set a byte limit and require valid encoding.

An external transparent overlay is usually easier to reason about than calling an internal text function:

observer reads owned snapshots
→ world_to_screen returns pixels
→ overlay draws labels
→ overlay clears and repeats

The overlay should ignore mouse input, match the target’s client area, and stop when the target closes.

Match the client area, not the outer window rectangle. Borders and title bars shift the origin. DPI scaling can make logical UI units differ from physical pixels, and borderless/fullscreen modes may change which coordinate system the compositor uses. Recalculate when the window moves, resizes, changes monitor, or changes DPI.

A basic offline overlay experiment

A basic offline overlay experiment.

Reproduce the original in-game AssaultCube ESP

Section titled “Reproduce the original in-game AssaultCube ESP”

The world-to-screen function and separate overlay already let you test the geometry. This exact-build in-game implementation applies the detours and DLL lifecycle from Lesson 8.3 to the game’s own print path.

The original 1.2.0.2 lab uses the same globals and player layout as the aimbot, plus these confirmed facts:

virtual screen: 2400 × 1800
screen center: (1200, 900)
print-text function: 0x0041_9880
final print hook: 0x0040_BE7E
hook resume: 0x0040_BE83
player name: 16 bytes at player + 0x225

For this specific virtual viewport, the experimentally calibrated projection is:

fn assaultcube_legacy_screen(
current: Angles,
desired: Angles,
) -> Option<(u32, u32)> {
let yaw_difference = shortest_angle_delta(desired.yaw, current.yaw);
let pitch_difference = current.pitch - desired.pitch;
let x = 1200.0 + yaw_difference * -30.0;
let y = 900.0 + pitch_difference * 25.0;
((0.0..=2400.0).contains(&x) && (0.0..=1800.0).contains(&y))
.then_some((x as u32, y as u32))
}

Iterate players 1..count, calculate each desired angle, and publish the projected position and name pointer into fixed atomic slots. The render hook reads those slots and calls 0x00419880 with the confirmed x, y, and text arguments. The player object owns the fixed 16-byte name field for the frame; the code never scans past that verified field while calculating its address.

The overwritten bytes at 0x0040BE7E are themselves a five-byte near call to 0x00419880. The installer calculates those expected bytes, refuses an unknown build, and replaces the call with a near jump to this compiled cave:

#[unsafe(naked)]
unsafe extern "C" fn assaultcube_esp_cave() {
core::arch::naked_asm!(
// Replay the original print call with an empty string, then add labels.
"lea ecx, [{empty}]",
"call {print}",
"pushfd",
"pushad",
"call {draw}",
"popad",
"popfd",
"jmp {resume}",
empty = sym EMPTY_TEXT,
print = const 0x0041_9880,
draw = sym draw_esp_names,
resume = const 0x0040_BE83,
);
}
pub fn install_assaultcube_esp_hook() -> anyhow::Result<LocalPatch> {
let expected = near_call(0x0040_BE7E, 0x0041_9880)?;
let replacement = near_jump(
0x0040_BE7E,
assaultcube_esp_cave as *const () as usize,
)?;
LocalPatch::apply(0x0040_BE7E, &expected, &replacement)
}

Start with the literal text Enemy at (0x100, 0x100) to prove the print call. Then use one bot, calibrate left/center/right, add pitch, add its name, and finally loop through all players. The finished result is the named ESP shown below.

AssaultCube ESP with player name

AssaultCube with a player name drawn by the overlay.

The matrix-based overlay earlier in this lesson is the more general technique. This target-specific section keeps the exact reverse-engineered internal print route so readers can reproduce the original game hack.

Build and inject the course DLL, enter a local AssaultCube bot match, and press F5. Press F5 again or End to drop LocalPatch and restore the exact original call:

Terminal window
.\target\i686-pc-windows-msvc\release\injector.exe `
ac_client.exe `
.\target\i686-pc-windows-msvc\release\gha_windows_labs.dll

The entire working update loop, projection, internal x86 calling convention, cave, and installer are in assaultcube_tools.rs. The F5 worker logic is in dll.rs.

assaultcube_tools.rs

windows-labs/src/windows_impl/ · Rust, 276 lines

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

windows-labs/src/windows_impl/ · Rust, 249 lines

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
}
Still image accompanying Player labels following the AssaultCube view
Player labels following the AssaultCube viewSilent demonstration · 10 seconds

The original ESP recording shows labels moving with the view. Follow one label while the camera turns: a stable label requires the player position, camera state, projection, and draw location to agree each frame.

Download Player labels following the AssaultCube view video

Test in this order:

  1. one stationary point directly ahead;
  2. move the camera left and right;
  3. move closer and farther;
  4. test a point behind the camera;
  5. resize or change resolution;
  6. move a point across the near and far clip planes;
  7. add several entities.

If a point behind the camera appears mirrored, the w rejection is missing or wrong.

Projecting a 3D box is not just two points

Section titled “Projecting a 3D box is not just two points”

To draw a 2D rectangle around a 3D bounds, transform all eight corners of an axis-aligned box and take the min/max of the surviving screen coordinates. Two opposite corners are insufficient after rotation. If the box crosses the near plane, some corners have unusable w; a rigorous result clips its edges against the near plane before taking bounds. Simply dropping those corners can make the box collapse or jump.

An oriented bounding box requires its local corners to pass through the object’s world transform first. A skeletal model may need pose-derived bounds because its rest-pose box no longer tightly encloses the animation.

Gizmos: the same projection, used by developers

Section titled “Gizmos: the same projection, used by developers”

Engine developers draw overlays with this same projection to see what the game cannot show on its own. A gizmo, or debug drawing, is a shape drawn for a single frame: a line, an arrow, a box around a collision shape, a circle for a light’s reach, the axes of an object, or a text label. The code calls something like line(a, b, colour) on every frame, the engine projects the two ends to pixels, and the line is drawn on top. When the code stops calling, the line is gone. Nothing was added to the game world.

The axes gizmo is the simplest. By convention red, green, and blue stand for the x, y, and z directions. For an object at (10, 5, 0), with each axis drawn 1 unit long, the engine projects four points and draws three lines from the first:

origin (10, 5, 0) -> pixel A
x end (11, 5, 0) -> pixel B red line A to B
y end (10, 6, 0) -> pixel C green line A to C
z end (10, 5, 1) -> pixel D blue line A to D

A bounding-box gizmo projects the box’s eight corners, as above, and joins them with 12 edges. A text gizmo puts a label at one projected point, which is what the labels of the overlay in this lesson are. An editor’s transform gizmo adds interaction: the three arrows are picked with the mouse ray from Lesson 7.5, and dragging moves the selected object along the chosen axis.

This is why a gizmo is a useful check on your own work. If the axes drawn at a player’s feet do not stay glued to the player when the camera turns, the projection, the snapshot, or the viewport is wrong, and the failure table below tells you which to suspect.

World-to-screen failures

Symptom First check
every point mirrored horizontally x basis/handedness
markers rotate opposite the camera view transform/inverse
correct in center, wrong near edges projection FOV or aspect ratio
correct at one resolution only viewport dimensions or stale aspect ratio
fixed offset from every object client-area origin/DPI/window border
jitter while camera or entities move incoherent snapshots
behind-camera labels flip onto screen w sign/rejection
labels vanish too early in depth wrong OpenGL/Direct3D depth convention
box jumps near the camera no near-plane edge clipping

Keep the observer in an offline match with bots. The transferable skill is the 3D-to-2D transformation pipeline used in editors, accessibility tools, debug HUDs, and visualization software.

Knowledge check

Reject points behind the camera

Why check clip-space w before the perspective divide?

Finished Lesson 7.9?

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.

Chapter 7: 11 lessons

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.