Skip to content
Lesson 7.7Camera Geometry and Rendering APIs

Recoil, Spread, and Camera Motion

Separate input, weapon kick, random spread, camera shake and other camera motion, and recovery to understand why the view moves after a shot.

Lesson 7.7
Read or listenPrint chapter or book
About 32 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.7Key ideas, reference and questions

A one-page summary of “Recoil, Spread, and Camera Motion” you can keep beside your notes. Plain Markdown.

Lesson 7.7 — Recoil, Spread, and Camera Motion: cheatsheet

  • Remember: View kick, weapon animation, and projectile spread can look related while coming from different calculations.
  • Also useful: Picture a spring pushed by each shot: it stores motion, overshoots, and settles. Use that picture only when measured recovery supports it.

Key terms

  • angle: An angle describes rotation.
  • enable_assaultcube_no_recoil: The compiled in-process implementation is enable_assaultcube_no_recoil.

Formulas and patterns

  • weapon fired; → recoil amount selected; → horizontal and vertical kick calculated; → camera/view state updated

Review

  • Q: Why record recoil as a time series instead of one final camera angle? A: The samples reveal the impulse, recovery, timing, and damping behavior
  • Q: Removing the camera kick from a weapon is tested and the view stops rising. What may you conclude? A: Only that the camera kick is gone; projectile spread may still be there
Download cheatsheet (.md)

On screen, recoil looks like the weapon pushing the camera. In code, it is usually one or more numbers added to a view model over time.

The game may keep separate values for:

  • the player’s intended yaw and pitch from the mouse;
  • an immediate weapon kick;
  • a recovery amount that eases back toward zero;
  • random spread used by projectile calculations;
  • camera shake used only for presentation;
  • the weapon model’s animation.

Those systems can produce similar movement while controlling different things. Removing a camera kick should not be assumed to remove projectile spread, and changing the weapon animation should not be assumed to change aim.

Player input
Base aim orientation
Weapon recoil impulse
Kick state
Camera orientation
Camera shake
Shot direction
Projectile spread
Rendered view
Weapon-model animation

This diagram deliberately gives rendered view and shot direction separate paths. If two systems read the same base aim but add different offsets, changing one will not necessarily change the other.

An angle describes rotation. Yaw usually turns left and right around a vertical axis. Pitch looks up and down. Games may store degrees, radians, fixed point units, or normalized values, so the number 90.0 has meaning only after measurement.

In an offline AssaultCube session, log view angles while:

  • standing still;
  • moving the mouse;
  • firing one shot;
  • firing a burst;
  • switching weapons.

Recoil may change the same yaw and pitch values as the mouse, or it may update separate kick values that are combined later.

Log time as well as values. A single before-and-after pair shows total movement; a short time series shows whether the kick happens instantly and how recovery behaves across later frames.

Give each sample a monotonic timestamp and, when available, the frame or simulation tick. Wall-clock timestamps can jump; a monotonic clock measures elapsed duration. Each sample also needs the weapon, fire mode, and whether the shot was accepted. Otherwise a reload or dry-fire sample can be mistaken for zero recoil.

Set a write breakpoint on the confirmed pitch field and fire once.

A recoil-related function in the debugger

A recoil-related function in the debugger.

Compare the call stack with an ordinary mouse movement. A path that appears only after firing is a useful lead.

Step upward until you can explain the inputs:

weapon fired
→ recoil amount selected
→ horizontal and vertical kick calculated
→ camera/view state updated

Tracing the recoil calculation

Tracing the recoil calculation.

The logged view angle mixes player input with weapon effects. Give the weapon’s temporary kick its own two-axis value so it can be compared across shots without treating every camera movement as recoil. The validity check rejects non-finite readings and values outside this lab’s expected range:

#[derive(Clone, Copy, Debug, Default)]
struct Recoil {
yaw: f32,
pitch: f32,
}
impl Recoil {
fn is_reasonable(self) -> bool {
self.yaw.is_finite()
&& self.pitch.is_finite()
&& self.yaw.abs() <= 90.0
&& self.pitch.abs() <= 90.0
}
}

Do not assume the units are degrees. Compare known view changes and weapon differences.

is_finite rejects NaN and infinity. Those special floating-point values can spread through later math and make comparisons or drawing unreliable. The 90.0 limits are a lab sanity check, not proof of the game’s real unit system.

Name samples with units:

#[derive(Clone, Copy, Debug)]
struct TimedKick {
seconds_after_shot: f32,
yaw_degrees: f32,
pitch_degrees: f32,
}

Degrees per frame and degrees per second are different quantities. A per-frame recovery constant produces different behavior when frame rate changes unless the engine runs that update on a fixed simulation tick.

A simple model keeps a kick displacement x and velocity v. Firing adds an impulse; each update pulls the displacement toward zero and damps velocity:

on shot: v ← v + impulse
update: acceleration = -stiffness × x - damping × v
v ← v + acceleration × dt
x ← x + v × dt

This damped-spring model explains overshoot and smooth return. Other games use a fixed curve, exponential decay, a table of weapon-specific offsets, or direct angle changes. Fit the simplest model supported by measured samples rather than assuming every recovery is a spring.

For exponential recovery, x(t) = x₀e^(-kt). Equal fractions disappear in equal time intervals. For linear recovery, equal absolute amounts disappear. Plotting a few samples makes the difference visible.

First build an observer that logs a short sequence:

#[derive(Debug)]
struct RecoilSample {
shot: u32,
before: Angles,
after: Angles,
}
impl RecoilSample {
fn delta(&self) -> Recoil {
Recoil {
yaw: shortest_angle_delta(self.before.yaw, self.after.yaw),
pitch: self.after.pitch - self.before.pitch,
}
}
}

This can reveal:

  • a fixed pattern;
  • random spread;
  • weapon-specific values;
  • accumulation and recovery over time.

Repeat the same shot many times. Compute mean kick to expose a fixed pattern and the spread around that mean to expose randomness. If shot number inside a burst matters, group by shot index instead of averaging the whole magazine together.

mean = sum(samples) / count
variance = sum((sample - mean)²) / (count - 1) when count > 1

Variance describes observed variation; it does not prove which random-number source or probability distribution produced it.

Test one instruction with a temporary patch

Section titled “Test one instruction with a temporary patch”

A temporary debugger patch can show whether one instruction contributes to recoil. Keep a copy of the original bytes so you can restore them, change one instruction, and compare the same controlled shot.

A candidate recoil instruction

A candidate recoil instruction.

If weapon animation still moves but the camera does not, you may have isolated view kick. If accuracy changes too, the instruction controls more than presentation.

Compare at least these observables separately:

Observable What it can tell you
stored base yaw/pitch player-input orientation
final camera matrix presentation actually rendered
shot direction or impact gameplay trajectory
weapon-model transform view-model animation
kick accumulator over time recovery state

Apply the AssaultCube 1.2.0.2 no-recoil patch

Section titled “Apply the AssaultCube 1.2.0.2 no-recoil patch”

The measured recoil layers above establish what the patch should change. The automated PatchPlan version below applies the verified patch lifecycle from Lesson 8.3. Its byte check, write, and restoration must all refer to the same pinned build.

The firing path begins around 0x0046363A; after breaking on the live pitch field while firing, the recoil store is found at 0x0045BAAD. Replace its complete three-byte instruction with:

const RECOIL_STORE: usize = 0x0045_BAAD;
const ORIGINAL_RECOIL_STORE: [u8; 3] = [0xD9, 0x5B, 0x44];
const NO_RECOIL: [u8; 3] = [0xDD, 0xD8, 0x90];

These bytes pop the pending x87 value with fstp st(0) and pad the remaining byte, so the recoil value is discarded instead of being stored to [ebx+0x44], the pitch field.

The older x87 floating-point unit behaves like a small stack of number registers. The original store removes its top value after writing it. Merely NOPing the whole instruction would leave that value on the x87 stack and could break later calculations. fstp st(0) discards the value while preserving the original stack effect; nop fills the final byte so the patch length remains three.

Use a verified patch:

let plan = PatchPlan::new(
RECOIL_STORE,
&ORIGINAL_RECOIL_STORE,
&NO_RECOIL,
)?;
let mut no_recoil = plan.apply(&process)?;

Fire a single shot before and after enabling it. Ammo and firing animation should still work, but the camera should no longer kick upward. Restore the original three bytes and confirm recoil returns.

Still image accompanying Recoil behavior during AssaultCube firing
Recoil behavior during AssaultCube firingSilent demonstration · 11 seconds

The original no-recoil demonstration shows firing with the camera effect altered. Watch camera motion separately from ammunition and firing animation; changing one recoil store does not establish that every weapon effect has changed.

Download Recoil behavior during AssaultCube firing video

Random spread: choosing a point in a circle

Section titled “Random spread: choosing a point in a circle”

Spread adds a random offset to the direction of a shot. The offset is usually a random point inside a small circle around the aim direction, and picking that point fairly is easier to get wrong than it looks.

The obvious recipe is a random distance r = R × u, with u chosen evenly between 0 and 1, at a random angle. It bunches the shots near the middle. Half of the values of u fall below 0.5, so half of the shots land inside the circle of radius 0.5 × R, but that circle has only (0.5)² = 25% of the area. The fix is to take a square root, r = R × √u, because the area inside a radius grows as the radius squared:

spread radius R = 2 degrees
u = 0.25 -> r = 2 × √0.25 = 2 × 0.5 = 1.0 degree (the naive recipe gives 0.5)
v = 0.125 -> angle = 360° × 0.125 = 45°
offset = (r × cos 45°, r × sin 45°) = (0.7071, 0.7071) degrees

The two values u and v come from a pseudo-random number generator. A game that seeds it with the same number gets the same “random” sequence every time, which is how replays and networked games keep every machine in agreement. Two runs that start from the same seed repeat their spread exactly, and runs that start from different seeds do not.

The spread offset is added to the shot direction, not to the camera, which is why the diagram above gives them separate paths.

Camera motion: shake, follow, orbit, and zoom

Section titled “Camera motion: shake, follow, orbit, and zoom”

The camera has more layers than recoil. Each calculates a position or viewing direction: shake adds an offset, follow tracks a subject, orbit rotates around a point, and zoom changes the framing.

  • Shake is a short random offset that fades. With an amplitude of 4 pixels that fades to 0 over 0.5 s, the largest offset at 0.1 s is 4 × (1 − 0.1 ÷ 0.5) = 3.2 pixels, at 0.25 s it is 2.0, and at 0.5 s it is 0. Many games track a “trauma” value from 0 to 1 and shake by its square, so a light hit of 0.5 shakes at only 0.25 of the maximum. Shake is presentation: it moves the picture, not the aim.
  • Follow cameras trail the player. A top-down 2D camera eases toward the player with the smoothing from Lesson 4.4, and often has a dead zone, a box around the screen’s centre inside which the player can move without moving the camera.
  • Orbit cameras circle a target. With z up, the camera sits at the target plus distance × (cos(pitch) × cos(yaw), cos(pitch) × sin(yaw), sin(pitch)) and always looks back at the target. For a target at the origin, a distance of 10, a yaw of 90°, and a pitch of 30°, that is 10 × (0.866 × 0, 0.866 × 1, 0.5) = (0, 8.66, 5).
  • First-person cameras sit at the character’s head and take yaw and pitch from the mouse. Pitch is clamped just short of straight up or down, so the view cannot flip over (Lesson 7.1). The weapon in the player’s hand, the view model, is often drawn with its own field of view, narrower than the world’s, so it does not look stretched.
  • Free and pan cameras are for editors: a free camera moves along its own forward and right directions, and a 2D pan camera moves opposite to the drag.
  • Zoom narrows the field of view for a perspective camera, which Lesson 7.1 showed scales the picture by 1.732 when it goes from 90° to 60°. For an orthographic camera, zooming shrinks the box of visible world, so halving its width doubles the pixels per unit.

This builder needs JavaScript. Here is the lesson’s worked example.

amplitude × (1 − time since hit ÷ fade time): with the lesson’s numbers, 4 × (1 − 0.1 ÷ 0.5), which gives 3.2. 4 px × (1 − 0.1 ÷ 0.5) = 3.2 px. The largest offset at 0.1 s is 3.2 px, and it shrinks in a straight line to 0 at 0.5 s. Shake is presentation: it moves the picture, not the aim.

Many effects are layers:

input angle
+ recoil kick
+ camera shake
+ animation sway
= final view

Do not label the first changing float “recoil.” Trace when it is produced and where it is combined.

Composition order matters for 3D rotations. Adding small yaw/pitch offsets is a common approximation, but full orientations may be matrices or quaternions. Large rotations do not generally commute: applying yaw then pitch can differ from pitch then yaw. Identify where the engine converts or combines its representation before assigning meaning to one component.

What changed What it does not prove
camera no longer rises projectile spread is gone
weapon model stops moving aim direction is unchanged
pitch write disappears no later camera transform adds kick
repeated shots look similar recoil is fully deterministic
one weapon uses an offset table every weapon uses that table

Keep this experiment offline. Its value is learning to separate a visible effect into measurable contributions.

The compiled in-process implementation is enable_assaultcube_no_recoil. After injection, F2 toggles it and restoration puts D9 5B 44 back.

game_hooks.rs

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

Show the codeHide the code
//! Exact x86 hooks used by the version-pinned course targets.

#![cfg(target_arch = "x86")]

use std::{
    ptr,
    sync::atomic::{AtomicBool, Ordering},
};

use anyhow::{Context, Result};
use windows::Win32::UI::Input::KeyboardAndMouse::{
    INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEINPUT, SendInput,
};

use super::{LocalPatch, input_structure_size, near_jump};

const WESNOTH_PLAYER_ROOT: usize = 0x017E_ED18;
const WESNOTH_GAME_OFFSET: usize = 0x0A90;
const WESNOTH_GOLD_OFFSET: usize = 0x0004;
const WESNOTH_TERRAIN_HOOK: usize = 0x00CC_AF8A;
const WESNOTH_TERRAIN_RESUME: usize = 0x00CC_AF90;

const AC_TRIGGER_HOOK: usize = 0x0040_AD9D;
const AC_TRIGGER_ORIGINAL: usize = 0x0046_07C0;
const AC_TRIGGER_RESUME: usize = 0x0040_ADA2;
const AC_RECOIL_STORE: usize = 0x0045_BAAD;
const AC_RADAR_FILTER: usize = 0x0040_9FB3;

const URT_RENDER_HOOK: usize = 0x0052_D2FD;
const URT_RENDER_RESUME: usize = 0x0052_D303;

pub static TARGET_UNDER_CROSSHAIR: AtomicBool = AtomicBool::new(false);

/// Resolves Wesnoth's two pointers and writes the live gold field.
///
/// # Safety
/// Call only inside 32-bit Wesnoth 1.14.9 after a local match has started.
unsafe fn set_wesnoth_gold(amount: u32) -> Result<()> {
    // SAFETY: the caller verified the target build; volatile access makes the
    // actual memory operation visible at this exact point.
    let player = unsafe { ptr::read_volatile(WESNOTH_PLAYER_ROOT as *const u32) } as usize;
    anyhow::ensure!(
        player != 0,
        "start a Wesnoth match before using the terrain hook"
    );

    let side_pointer_address = player
        .checked_add(WESNOTH_GAME_OFFSET)
        .context("Wesnoth side-pointer address overflowed")?;
    // SAFETY: the verified player structure owns a 32-bit pointer at +0xA90.
    let side = unsafe { ptr::read_volatile(side_pointer_address as *const u32) } as usize;
    anyhow::ensure!(side != 0, "Wesnoth side pointer is null");

    let gold_address = side
        .checked_add(WESNOTH_GOLD_OFFSET)
        .context("Wesnoth gold address overflowed")?;
    // SAFETY: the verified side structure owns its four-byte gold field at +4.
    unsafe { ptr::write_volatile(gold_address as *mut u32, amount) };
    Ok(())
}

pub fn write_wesnoth_gold(amount: u32) -> Result<()> {
    // SAFETY: the DLL worker calls this only after matching wesnoth.exe. The
    // pointer resolver still rejects the normal "no active match" null state.
    unsafe { set_wesnoth_gold(amount) }
}

extern "C" fn wesnoth_cave_body() {
    // A naked cave cannot return `Result`, so the worker verifies the target and
    // this tiny body simply leaves the game unchanged if the match is not ready.
    // SAFETY: the cave is installed only after the exact Wesnoth hook bytes
    // match; the helper checks every nullable pointer in the live chain.
    let _ = unsafe { set_wesnoth_gold(888) };
}

#[unsafe(naked)]
unsafe extern "C" fn wesnoth_terrain_cave() {
    core::arch::naked_asm!(
        "pushfd",
        "pushad",
        "call {body}",
        "popad",
        "popfd",
        // Replay the behavior of the six bytes replaced at the hook site.
        "mov eax, dword ptr [ecx]",
        "lea esi, [esi]",
        "jmp {resume}",
        body = sym wesnoth_cave_body,
        resume = const WESNOTH_TERRAIN_RESUME,
    );
}

extern "C" fn record_crosshair_target(value: u32) {
    TARGET_UNDER_CROSSHAIR.store(value != 0, Ordering::Release);
}

#[unsafe(naked)]
unsafe extern "C" fn assaultcube_trigger_cave() {
    core::arch::naked_asm!(
        // The removed instruction was this call. Run it first so EAX contains
        // the game's real target-under-crosshair result.
        "call {original}",
        "pushfd",
        "pushad",
        "push eax",
        "call {record}",
        "add esp, 4",
        "popad",
        "popfd",
        "jmp {resume}",
        original = const AC_TRIGGER_ORIGINAL,
        record = sym record_crosshair_target,
        resume = const AC_TRIGGER_RESUME,
    );
}

#[unsafe(naked)]
unsafe extern "C" fn urban_terror_render_cave() {
    core::arch::naked_asm!(
        "pushfd",
        "pushad",
        "mov dword ptr [ebx + 4], 0x0D",
        "popad",
        "popfd",
        // Replay the complete instruction that the detour replaced.
        "mov dword ptr [0x0102AE98], ebx",
        "jmp {resume}",
        resume = const URT_RENDER_RESUME,
    );
}

fn six_byte_detour(hook: usize, cave: usize) -> Result<[u8; 6]> {
    let jump = near_jump(hook, cave)?;
    let mut replacement = [0x90_u8; 6];
    replacement[..5].copy_from_slice(&jump);
    Ok(replacement)
}

pub fn install_wesnoth_terrain_hook() -> Result<LocalPatch> {
    // mov eax,[ecx] ; lea esi,[esi+eiz]
    const ORIGINAL: [u8; 6] = [0x8B, 0x01, 0x8D, 0x74, 0x26, 0x00];
    let cave = wesnoth_terrain_cave as *const () as usize;
    let replacement = six_byte_detour(WESNOTH_TERRAIN_HOOK, cave)?;
    LocalPatch::apply(WESNOTH_TERRAIN_HOOK, &ORIGINAL, &replacement)
}

pub fn install_assaultcube_trigger_hook() -> Result<LocalPatch> {
    // call 0x004607C0, encoded relative to the end of this instruction.
    const ORIGINAL: [u8; 5] = [0xE8, 0x1E, 0x5A, 0x05, 0x00];
    let cave = assaultcube_trigger_cave as *const () as usize;
    let replacement = near_jump(AC_TRIGGER_HOOK, cave)?;
    LocalPatch::apply(AC_TRIGGER_HOOK, &ORIGINAL, &replacement)
}

pub fn install_urban_terror_memory_wallhook() -> Result<LocalPatch> {
    // mov dword ptr [0x0102AE98],ebx
    const ORIGINAL: [u8; 6] = [0x89, 0x1D, 0x98, 0xAE, 0x02, 0x01];
    let cave = urban_terror_render_cave as *const () as usize;
    let replacement = six_byte_detour(URT_RENDER_HOOK, cave)?;
    LocalPatch::apply(URT_RENDER_HOOK, &ORIGINAL, &replacement)
}

pub fn enable_assaultcube_no_recoil() -> Result<LocalPatch> {
    // fstp dword ptr [ebx+0x44] -> fstp st(0), nop
    LocalPatch::apply(AC_RECOIL_STORE, &[0xD9, 0x5B, 0x44], &[0xDD, 0xD8, 0x90])
}

pub fn enable_assaultcube_show_all_radar() -> Result<LocalPatch> {
    // 0F 85 is a six-byte near JNE. Capture its four-byte destination exactly,
    // NOP the full branch, and let LocalPatch restore all six captured bytes.
    LocalPatch::apply_masked(
        AC_RADAR_FILTER,
        &[Some(0x0F), Some(0x85), None, None, None, None],
        &[0x90; 6],
    )
}

pub fn send_left_mouse(pressed: bool) -> Result<()> {
    let flags = if pressed {
        MOUSEEVENTF_LEFTDOWN
    } else {
        MOUSEEVENTF_LEFTUP
    };
    let input = INPUT {
        r#type: INPUT_MOUSE,
        Anonymous: INPUT_0 {
            mi: MOUSEINPUT {
                dx: 0,
                dy: 0,
                mouseData: 0,
                dwFlags: flags,
                time: 0,
                dwExtraInfo: 0,
            },
        },
    };
    // SAFETY: `input` is a fully initialized INPUT value and the structure size
    // is the one expected by this build of Windows.
    let sent = unsafe { SendInput(&[input], input_structure_size()) };
    anyhow::ensure!(sent == 1, "SendInput sent {sent} of 1 mouse event");
    Ok(())
}
Knowledge check

Model camera motion

Why record recoil as a time series instead of one final camera angle?

Finished Lesson 7.7?

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.