Skip to content
Lesson 7.4Camera Geometry and Rendering APIs

Render Categories and Color Probes

Use temporary color to distinguish render categories while accounting for materials, lighting, blending, color space, and the several passes that draw one frame.

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

A one-page summary of “Render Categories and Color Probes” you can keep beside your notes. Plain Markdown.

Lesson 7.4 — Render Categories and Color Probes: cheatsheet

  • Remember: Count both incorrect highlights and missed targets; a few convincing colored players cannot establish a reliable classifier.
  • Also useful: Treat the first draw filter as a hypothesis written on a sticky label, then use the colored result to check where the label belongs.

Key terms

  • chams: This technique is often called chams.
  • pass: The engine renders the scene several times for different purposes, in order, and each sweep is a pass.

Formulas and patterns

  • precision = true_positive / (true_positive + false_positive); recall = true_positive / (true_positive + false_negative)

Review

  • Q: Why temporarily color one suspected render category at a time? A: One controlled change links a recorded call pattern to a visible model group
  • Q: What is a temporary solid colour (chams) used for in this course? A: As a visual debugger that shows which draw calls a classification rule actually catches
Download cheatsheet (.md)

A solid temporary color can answer a classification question:

Do these draw calls all belong to player models, or did our filter catch unrelated geometry?

This technique is often called chams. In this course it is a visual debugger for an offline target.

An original player texture

An original player texture.

A pixel’s color has several contributors

Section titled “A pixel’s color has several contributors”

“Set the color to red” sounds simple, but the final framebuffer value may combine:

vertex color
× sampled texture
× material/light result
→ fragment output
→ blending with the existing framebuffer
→ display conversion

Multiplication is only one common model; a shader can compute anything. In the fixed-function path used here, disabling selected arrays and textures reduces the number of active contributors so the diagnostic color is easier to interpret.

Alpha does not automatically mean transparency. Blending must be enabled with specific source/destination blend factors and a combination formula, and draw order still matters. Likewise, RGB values may be interpreted as linear light or sRGB-encoded display values. A debug color only needs to be distinctive, but exact color comparison requires knowing the framebuffer’s color-space conversion.

Before changing a draw’s color, turn what we observed into an explicit category. In this course build, the first trial labels a draw with more than 500 indices as OtherPlayer; all other draws remain Unknown. This index-count classifier will be judged by the colored results, not assumed true because it compiled:

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RenderCategory {
LocalPlayer,
OtherPlayer,
Weapon,
World,
Unknown,
}
#[derive(Clone, Copy, Debug)]
struct DrawSample {
count: i32,
texture: u32,
call_site: usize,
}
fn classify(sample: DrawSample) -> RenderCategory {
if sample.count > 500 {
RenderCategory::OtherPlayer
} else {
RenderCategory::Unknown
}
}

For this exact Urban Terror build, count > 500 is the original course filter. It is intentionally simple so the color result can help you test what the filter catches.

Keep the classifier pure. It should not call OpenGL or change memory, which makes it easy to test with recorded samples.

Purity also makes uncertainty visible. Return Unknown when the sample does not match any category. A confident-looking wrong label is harder to diagnose than an explicit unknown.

recorded draw state ──► pure classifier ──► category
│
▼
temporary debug style

A colored result gives immediate feedback, but “some players turned red” is not enough. Count four outcomes across a small labeled capture:

Outcome Name
player colored true positive
non-player colored false positive
player left unchanged false negative
non-player left unchanged true negative

Changing a threshold usually trades false positives against false negatives. Add independent features only when experiments show they help: call site, texture, model transform, or another stable render property. Avoid piling on conditions that merely memorize one map and graphics setting.

For a labeled set, calculate:

precision = true_positive / (true_positive + false_positive)
recall = true_positive / (true_positive + false_negative)

Precision asks “when the classifier says player, how often is it right?” Recall asks “of all player draws, how many did it find?” If a denominator is zero, report the metric as undefined instead of inventing a perfect score.

Keep the recorded samples separate from the classifier implementation. Then a new classifier can be scored against the same samples, so a change in precision or recall comes from the classifier and not from a different capture.

This board needs JavaScript to move things around. Here is the lesson’s sorting:

Coloured, not a player
  • Crate with a player-sized vertex count. The rule coloured it but it is not a player: a false positive. A vertex count is a quick first filter, not a durable identifier.
Coloured player
  • Player, standing. The rule coloured it and it really is a player: a true positive.
  • Player, running. Coloured and really a player: a true positive.
  • Player, far away. Coloured and really a player: a true positive.
  • Player, aiming. Coloured and really a player: a true positive.
Player left unchanged
  • Player drawn in the shadow pass. A player draw the rule left unchanged: a false negative. The same player appears in several passes, and a filter that recognises one does not recognise them all.
  • Player portrait in the HUD. Another pass of the same player (the HUD portrait) that the rule did not recognise: a false negative.
Not coloured and not a player
  • Wall. Not a player and not coloured: a true negative. Correct, and it does not show up in precision or recall.
  • Sky. Not a player and not coloured: a true negative.
  • Floor. Not a player and not coloured: a true negative.

The rendering layer can choose a debug style:

#[derive(Clone, Copy)]
struct DebugColor {
red: f32,
green: f32,
blue: f32,
alpha: f32,
}
fn category_color(category: RenderCategory) -> Option<DebugColor> {
match category {
RenderCategory::OtherPlayer => Some(DebugColor {
red: 0.95,
green: 0.25,
blue: 0.12,
alpha: 1.0,
}),
_ => None,
}
}

Each channel is a fraction of full brightness, so a paint program shows it as that fraction times 255. Here 0.95 × 255 = 242.25 (242), 0.25 × 255 = 63.75 (64), and 0.12 × 255 = 30.6 (31): rgb(242, 64, 31)the other-player colour, a strong red-orange that is easy to tell from a game’s own palette.

At the hook boundary:

  1. capture the current texture/color/depth state;
  2. apply the debug state;
  3. forward exactly one draw;
  4. restore every captured value.

The state must cover both server-side values such as enable flags and client array state such as color or texture-coordinate arrays. Capturing only the visible color is insufficient if the original draw obtains its color from a bound array.

A color-coded model in the offline lab

A colour-coded model in the offline lab.

Understand the build-specific Urban Terror fallback

Section titled “Understand the build-specific Urban Terror fallback”

The pure classifier and restoration of the original whole-draw state above are the general lesson. The following fallback is an optional exact-build in-process case. It applies the hook lifecycle from Lesson 8.3 at a less convenient point inside the draw function.

The clean design above wraps one whole draw: capture, change, draw, restore. The course’s Urban Terror 4.3.4 cave is different. It runs in the middle of glDrawElements, so it cannot surround the call and restore a snapshot after that same draw. Instead, it applies a debug state to player-sized draws and puts assumed engine defaults back on the next ordinary draw.

The fallback uses these functions and constants:

type GlToggle = unsafe extern "system" fn(capability: u32);
type GlColor4f = unsafe extern "system" fn(r: f32, g: f32, b: f32, a: f32);
const GL_COLOR_MATERIAL: u32 = 0x0B57;
const GL_COLOR_ARRAY: u32 = 0x8076;
const GL_TEXTURE_COORD_ARRAY: u32 = 0x8078;

For comparison, a whole-call wrapper can make the color transition explicit around one call:

// SAFETY: every pointer was resolved from the current opengl32.dll and this
// hook runs on the thread with the current OpenGL context.
unsafe {
gl_disable_client_state(GL_TEXTURE_COORD_ARRAY);
gl_disable_client_state(GL_COLOR_ARRAY);
gl_enable(GL_COLOR_MATERIAL);
gl_color4f(1.0, 0.6, 0.6, 1.0);
original(mode, count, element_type, indices);
gl_enable_client_state(GL_TEXTURE_COORD_ARRAY);
gl_enable_client_state(GL_COLOR_ARRAY);
gl_disable(GL_COLOR_MATERIAL);
gl_color4f(1.0, 1.0, 1.0, 1.0);
}

The first gl_color4f call sets the highlight, (1.0, 0.6, 0.6), which is rgb(255, 153, 153)light red because 0.6 × 255 = 153. The last one resets the colour to rgb(255, 255, 255)white.

The pinned Urban Terror cave runs inside glDrawElements, so it cannot literally use that whole-call wrapper. For player-sized draws, its companion depth calls use depth_range(0.0, 0.0) and depth_func(GL_ALWAYS) so the highlighted geometry passes the depth test. On the next ordinary draw, the fallback sets depth_range(0.0, 1.0) and depth_func(GL_LEQUAL) along with the color defaults shown above. Player-sized models should appear through walls in bright red. If the HUD, world, or later effects stay tinted, those assumed defaults did not match the state owned by that render pass.

Try count <= 500, count > 500, and no filter while logging samples. The visual differences let you prove which calls your classifier actually catches instead of treating the threshold as magic.

The compiled DLL uses a naked x86 cave inside Urban Terror’s glDrawElements path. It resolves all seven required OpenGL exports, switches to OPENGL_CHAMS, and applies this documented fallback from the render thread. Inject it and press F3 to toggle chams; press End to restore normal rendering and the original instruction:

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

Read the full implementation in opengl_hooks.rs, especially apply_urban_terror_gl_state and urban_terror_opengl_cave. Nothing is hidden behind a placeholder.

opengl_hooks.rs

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

Show the codeHide the code
//! Urban Terror 4.3.4 OpenGL wallhack/chams hook from the original course lab.

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

use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};

use anyhow::{Context, Result};
use windows::{
    Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress},
    core::{s, w},
};

use super::{LocalPatch, near_jump};

pub const OPENGL_OFF: u32 = 0;
pub const OPENGL_WALLHACK: u32 = 1;
pub const OPENGL_CHAMS: u32 = 2;

const GL_LEQUAL: u32 = 0x0203;
const GL_ALWAYS: u32 = 0x0207;
const GL_COLOR_MATERIAL: u32 = 0x0B57;
const GL_COLOR_ARRAY: u32 = 0x8076;
const GL_TEXTURE_COORD_ARRAY: u32 = 0x8078;

static OPENGL_MODE: AtomicU32 = AtomicU32::new(OPENGL_OFF);
static DRAW_RETURN: AtomicUsize = AtomicUsize::new(0);
static DEPTH_FUNC: AtomicUsize = AtomicUsize::new(0);
static DEPTH_RANGE: AtomicUsize = AtomicUsize::new(0);
static COLOR_4F: AtomicUsize = AtomicUsize::new(0);
static ENABLE: AtomicUsize = AtomicUsize::new(0);
static DISABLE: AtomicUsize = AtomicUsize::new(0);
static ENABLE_CLIENT_STATE: AtomicUsize = AtomicUsize::new(0);
static DISABLE_CLIENT_STATE: AtomicUsize = AtomicUsize::new(0);

type GlDepthFunc = unsafe extern "system" fn(u32);
type GlDepthRange = unsafe extern "system" fn(f64, f64);
type GlColor4f = unsafe extern "system" fn(f32, f32, f32, f32);
type GlState = unsafe extern "system" fn(u32);

fn address(slot: &AtomicUsize) -> Option<usize> {
    let value = slot.load(Ordering::Acquire);
    (value != 0).then_some(value)
}

// This mid-glDrawElements cave cannot wrap one whole draw, so the non-highlight
// branch restores the defaults observed in the pinned Urban Terror 4.3.4 build.
// It is deliberately not advertised as a general OpenGL state snapshot. A
// reusable hook should capture/query the exact previous state at a boundary
// where it can restore that state immediately after forwarding one draw.
extern "C" fn apply_urban_terror_gl_state(count: u32) {
    let mode = OPENGL_MODE.load(Ordering::Acquire);
    let (Some(depth_func), Some(depth_range)) = (address(&DEPTH_FUNC), address(&DEPTH_RANGE))
    else {
        return;
    };
    // SAFETY: install_urban_terror_opengl_hook resolved these opengl32 exports
    // and stored function pointers with their documented Windows ABI.
    let depth_func: GlDepthFunc = unsafe { std::mem::transmute(depth_func) };
    // SAFETY: same proof as above for glDepthRange.
    let depth_range: GlDepthRange = unsafe { std::mem::transmute(depth_range) };

    let highlighted = mode != OPENGL_OFF && count > 500;
    // SAFETY: a current Urban Terror render context called this hook.
    unsafe {
        if highlighted {
            depth_range(0.0, 0.0);
            depth_func(GL_ALWAYS);
        } else {
            depth_range(0.0, 1.0);
            depth_func(GL_LEQUAL);
        }
    }

    if mode != OPENGL_CHAMS {
        return;
    }
    let (Some(color), Some(enable), Some(disable), Some(enable_client), Some(disable_client)) = (
        address(&COLOR_4F),
        address(&ENABLE),
        address(&DISABLE),
        address(&ENABLE_CLIENT_STATE),
        address(&DISABLE_CLIENT_STATE),
    ) else {
        return;
    };
    // SAFETY: each address was resolved from the matching named OpenGL export.
    let color: GlColor4f = unsafe { std::mem::transmute(color) };
    // SAFETY: `enable` is the address returned for the glEnable export.
    let enable: GlState = unsafe { std::mem::transmute(enable) };
    // SAFETY: `disable` is the address returned for the glDisable export.
    let disable: GlState = unsafe { std::mem::transmute(disable) };
    // SAFETY: this address came from glEnableClientState, which has this ABI.
    let enable_client: GlState = unsafe { std::mem::transmute(enable_client) };
    // SAFETY: this address came from glDisableClientState, which has this ABI.
    let disable_client: GlState = unsafe { std::mem::transmute(disable_client) };

    // SAFETY: these are standard OpenGL 1.1 state calls on the render thread.
    unsafe {
        if highlighted {
            disable_client(GL_TEXTURE_COORD_ARRAY);
            disable_client(GL_COLOR_ARRAY);
            enable(GL_COLOR_MATERIAL);
            color(1.0, 0.6, 0.6, 1.0);
        } else {
            enable_client(GL_TEXTURE_COORD_ARRAY);
            enable_client(GL_COLOR_ARRAY);
            disable(GL_COLOR_MATERIAL);
            color(1.0, 1.0, 1.0, 1.0);
        }
    }
}

#[unsafe(naked)]
unsafe extern "C" fn urban_terror_opengl_cave() {
    core::arch::naked_asm!(
        "pushfd",
        "pushad",
        // At this point in glDrawElements, EBX holds the index count. This is
        // the value the original C++ course cave recovered from saved EBX.
        "push ebx",
        "call {apply}",
        "add esp, 4",
        "popad",
        "popfd",
        // Replay all six bytes replaced at glDrawElements + 0x16.
        "mov esi, dword ptr [esi + 0xA18]",
        "jmp dword ptr [{resume}]",
        apply = sym apply_urban_terror_gl_state,
        resume = sym DRAW_RETURN,
    );
}

fn resolve(
    module: windows::Win32::Foundation::HMODULE,
    name: windows::core::PCSTR,
) -> Result<usize> {
    // SAFETY: the module is live and every caller passes a static export name.
    let function = unsafe { GetProcAddress(module, name) }
        .context("required opengl32 export was not found")?;
    Ok(function as *const () as usize)
}

pub fn install_urban_terror_opengl_hook(mode: u32) -> Result<LocalPatch> {
    anyhow::ensure!(
        matches!(mode, OPENGL_WALLHACK | OPENGL_CHAMS),
        "invalid OpenGL lab mode"
    );
    // SAFETY: the name is a static, zero-terminated UTF-16 string.
    let opengl = unsafe { GetModuleHandleW(w!("opengl32.dll")) }
        .context("opengl32.dll is not loaded yet; enter a rendered game")?;

    DEPTH_FUNC.store(resolve(opengl, s!("glDepthFunc"))?, Ordering::Release);
    DEPTH_RANGE.store(resolve(opengl, s!("glDepthRange"))?, Ordering::Release);
    COLOR_4F.store(resolve(opengl, s!("glColor4f"))?, Ordering::Release);
    ENABLE.store(resolve(opengl, s!("glEnable"))?, Ordering::Release);
    DISABLE.store(resolve(opengl, s!("glDisable"))?, Ordering::Release);
    ENABLE_CLIENT_STATE.store(
        resolve(opengl, s!("glEnableClientState"))?,
        Ordering::Release,
    );
    DISABLE_CLIENT_STATE.store(
        resolve(opengl, s!("glDisableClientState"))?,
        Ordering::Release,
    );
    let draw_elements = resolve(opengl, s!("glDrawElements"))?;
    let hook = draw_elements
        .checked_add(0x16)
        .context("glDrawElements hook address overflowed")?;
    DRAW_RETURN.store(hook + 6, Ordering::Release);
    OPENGL_MODE.store(mode, Ordering::Release);

    // mov esi,dword ptr [esi+0xA18]
    const ORIGINAL: [u8; 6] = [0x8B, 0xB6, 0x18, 0x0A, 0x00, 0x00];
    let jump = near_jump(hook, urban_terror_opengl_cave as *const () as usize)?;
    let mut replacement = [0x90_u8; 6];
    replacement[..5].copy_from_slice(&jump);
    LocalPatch::apply(hook, &ORIGINAL, &replacement)
}

pub fn set_urban_terror_opengl_mode(mode: u32) -> Result<()> {
    anyhow::ensure!(
        matches!(mode, OPENGL_OFF | OPENGL_WALLHACK | OPENGL_CHAMS),
        "invalid OpenGL lab mode"
    );
    OPENGL_MODE.store(mode, Ordering::Release);
    Ok(())
}

OpenGL is a state machine. A color or depth change remains active until another call changes it. If the menu turns orange, the wrapper did not restore something.

Keep state changes in the narrowest possible scope and test:

  • normal models before the target draw;
  • the target model;
  • normal models after it;
  • menus and text;
  • level reloads.

Also test early returns and errors. Scope guards help with CPU state, but graphics APIs may require restoration on the render thread while the correct context is current. A guard dropped on another thread is not equivalent cleanup.

Most frames are not drawn in one sweep. The engine renders the scene several times for different purposes, in order, and each sweep is a pass. A pass starts by clearing its target to a clear colour, and clearing depth to its farthest value, so the last frame’s picture is not left behind:

Shadow pass
depth seen from the light
Opaque pass
solid objects
Sky
Transparent pass
far to near
Post-processing
full-screen effects
UI pass
menus and text
Present
  • Shadows. The shadow pass draws the scene’s depth as seen from the light into a texture called a shadow map. In the main pass, each pixel’s position is converted to the light’s view and compared with the stored depth: if something nearer was stored, the pixel is in shadow. A surface point 7.50 away from the light would be compared with its own stored depth, 7.50, and rounding could make it shadow itself, so the test uses a small bias: shadowed only if the stored depth is less than 7.50 − 0.05 = 7.45. It is not, so the point is lit.
  • Sky and fog. A skybox is a cube texture drawn so that it always looks infinitely far away. Fog blends a surface toward a haze colour with distance. With the fog starting at 10 and ending at 50, a surface 30 away has the factor (50 − 30) ÷ (50 − 10) = 0.5, so a surface of rgb(200, 100, 50) in haze rgb(150, 150, 150) is drawn as rgb(175, 125, 100).
  • Post-processing. The finished picture is stored as a texture and a full-screen rectangle is drawn with a shader that reads it. Bloom blurs the brightest parts and adds them back, and colour grading remaps colours. Tone mapping compresses HDR (high dynamic range) values, where lighting can produce colours above 1.0, into the 0 to 1 range a display can show, as Lesson 7.2 works through with numbers.
  • Render to texture. A pass can draw into an off-screen texture, not the screen. The texture can then be used as a mirror, a security-camera monitor, a minimap, or a screen shown inside the 3D world.
  • More than one camera. Each camera draws into its own rectangle, or its own texture. Split-screen is two cameras drawing into the left and right halves of one window.
  • Forward and deferred. A forward renderer lights each object as it draws it. A deferred renderer first draws position, normal, and colour for every pixel into extra textures, then lights the pixels in one later pass, which makes many lights affordable but handles see-through surfaces less easily.

The same player may appear in several passes: shadow, depth prepass, main color, outline, reflection, or HUD portrait. A filter that recognizes one of those draws does not automatically recognize the object everywhere. Conversely, a call site may draw many categories through a shared material pass.

When color appears twice, through a mirror, or only in a shadow, add the current framebuffer, shader/program, and pass position to the draw sample before changing the category predicate.

Identify a category with more than one number

Section titled “Identify a category with more than one number”

A vertex count can be a quick first filter, not a durable identifier. Combine independent draw properties and return Unknown when they disagree.

fn likely_player(sample: DrawSample, profile: &TargetProfile) -> bool {
profile.player_counts.contains(&sample.count)
&& profile.player_textures.contains(&sample.texture)
&& profile.player_call_sites.contains(&sample.call_site)
}

Version the profile and refuse to activate on an unrecognized build.

The exact-build result has four observable checks:

  • the normal frame is unchanged with classification disabled;
  • only the intended offline model category changes color;
  • the exact-build fallback leaves no visible state leak across the tested frame, while a reusable whole-draw wrapper restores captured state after every call;
  • unknown samples remain unknown instead of being forced into a category.
Knowledge check

Design a visual experiment

Why temporarily color one suspected render category at a time?

Finished Lesson 7.4?

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.