Skip to content
Lesson 7.10Camera Geometry and Rendering APIs

Direct3D Interfaces and Vtables

Understand how COM vtables expose Direct3D methods, why export lookup differs from OpenGL, and how a reversible swap-chain hook works.

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

A one-page summary of “Direct3D Interfaces and Vtables” you can keep beside your notes. Plain Markdown.

Lesson 7.10 — Direct3D Interfaces and Vtables: cheatsheet

  • Remember: A COM method receives its interface pointer before the visible arguments; the replacement must include that hidden receiver.
  • Also useful: Derive a vtable like a numbered family list: inherited entries come first, then the new interface’s own methods in declaration order.

Key terms

  • export: An export is a function a DLL makes findable by name.
  • DrawIndexed: Neither are DrawIndexed and Draw.
  • vtable: A C++ object may begin with a pointer to a table of function addresses, called a vtable.
  • IDXGISwapChain: Button 8 on an IDXGISwapChain is labeled Present by the interface contract, but the panel does not contain a universal street address for the code behind it.
  • Present: Present is index 8, and now you know why, which means you can work out ResizeBuffers the same way instead of searching for it.

Formulas and patterns

  • swap_chain -> 0x0000_01F4_8A20_0000 the object; read [swap_chain] -> 0x0000_7FFA_1C30_5000 its vtable; vtable + 8 * 8 -> 0x0000_7FFA_1C30_5040 the slot for method 8; read that slot -> 0x0000_7FFA_1C28_9A10 the function that runs

Review

  • Q: IDXGISwapChain inherits IUnknown (3 methods), then IDXGIObject (4 methods), then IDXGIDeviceSubObject (1 method), and declares Present first. Which vtable index is Present, and why does that reasoning matter? A: Index 8, because inherited methods come first, so the count can be repeated for any method
  • Q: Why does searching a library's export table for the name of a COM interface method find nothing? A: COM hands out objects, so methods are reached through a vtable, not an exported name
Download cheatsheet (.md)

Lesson 7.3 found glDrawElements by its exported name, and the Urban Terror wallhack in Lesson 8.8 detours an instruction inside it. OpenGL is a C API: opengl32.dll exports that function, so GetProcAddress can hand you its entry address. Lesson 8.4 used the import table as a different redirection point for named functions.

An export is a function a DLL makes findable by name. Ask GetProcAddress for Present in d3d11.dll, or inspect that DLL’s export list: the method is not there. Neither are DrawIndexed and Draw. The public names are mostly creators such as D3D11CreateDevice, not the method that runs once per frame. Lesson 5.3 showed where a PE file stores its export list; this lookup now uses that file-format detail.

This is not obfuscation. Direct3D is built on COM, the Component Object Model — Microsoft’s convention for handing out objects whose methods are reached through a table of function pointers rather than by name. COM does not expose methods as exports. It exposes objects.

A C++ object may begin with a pointer to a table of function addresses, called a vtable. A COM interface pointer uses that arrangement as a published ABI contract, rather than leaving it for us to infer from disassembly. Lesson 3.8 showed the same memory shape in a game object:

Picture a numbered control panel. Button 8 on an IDXGISwapChain is labeled Present by the interface contract, but the panel does not contain a universal street address for the code behind it. One object supplies a vtable pointer; slot 8 in that table supplies the function pointer for this implementation. The panel picture helps with the stable number. The two real memory reads below are what locate the code in this process:

swap_chain -> 0x0000_01F4_8A20_0000 the object
read [swap_chain] -> 0x0000_7FFA_1C30_5000 its vtable
vtable + 8 * 8 -> 0x0000_7FFA_1C30_5040 the slot for method 8
read that slot -> 0x0000_7FFA_1C28_9A10 the function that runs

Two lookups, the same shape as the virtual call you traced in Chapter 3. The difference is that here the layout is a contract. Microsoft cannot reorder the methods of a published interface without breaking every compiled program that uses it, which is what makes a vtable index a usable, stable fact.

Each slot is one pointer wide: eight bytes in a 64-bit process, four in a 32-bit one. Get that wrong and every index lands halfway between two entries.

A COM method address takes two memory readsThe first field of the object points to a table of method pointers. Slot 8 of that table is the method.
  1. 1 Read 1: the first field is the vtable address

    Swap-chain object
    0x000001F48A200000
    0x00007FFA1C305000first field: read 1
    other fields

    The swap-chain object lives at 0x000001F48A200000. Reading its first field gives 0x00007FFA1C305000, the start of the vtable.

  2. 2 Slot 8 is at offset 0x40

    vtable
    0x00007FFA1C305000
    slots 0–78 slots × 8 bytes = 0x40
    slot 80x00007FFA1C305040

    Eight slots of eight bytes put slot 8 at 0x00007FFA1C305000 + 0x40 = 0x00007FFA1C305040.

  3. 3 Read 2: slot 8 holds the Present code address

    Slot 8, then the code it names
    slot 8read 2
    Present implementation0x00007FFA1C289A10

    Reading slot 8 gives 0x00007FFA1C289A10, where the Present implementation starts.

The first field of the object points to a table of method pointers. Slot 8 of that table is the method.

COM dispatch keeps the hidden receiver. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

receiver = selected_swap_chain
table = read_pointer(receiver)
entry = table + 8 * 8
present = read_pointer(entry)
present(receiver, sync_interval, flags)
record which instance was called

Result: Present received swap chain A. A slot number locates a pointer; it is not a universal function address.

Final values: object = A; receiver = 0x1000; calls_A = 1; calls_B = 0; table = 0x3000; entry = 0x3040; present = 0x4000.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    object = A.

  2. Step 2, line 1. The two instances share this illustrative implementation table.

    object = A; receiver = 0x1000; calls_A = 0; calls_B = 0.

  3. Step 3, line 2. The first pointer supplies the table.

    object = A; receiver = 0x1000; calls_A = 0; calls_B = 0; table = 0x3000.

  4. Step 4, line 3. Eight entries times eight bytes gives offset 0x40.

    object = A; receiver = 0x1000; calls_A = 0; calls_B = 0; table = 0x3000; entry = 0x3040.

  5. Step 5, line 4. Read this implementation’s pointer from the selected slot.

    object = A; receiver = 0x1000; calls_A = 0; calls_B = 0; table = 0x3000; entry = 0x3040; present = 0x4000.

  6. Step 6, line 5. The hidden first parameter is 0x1000, the selected interface pointer.

    object = A; receiver = 0x1000; calls_A = 1; calls_B = 0; table = 0x3000; entry = 0x3040; present = 0x4000.

  7. Step 7, line 6. Result: Present received swap chain A. A slot number locates a pointer; it is not a universal function address.

    object = A; receiver = 0x1000; calls_A = 1; calls_B = 0; table = 0x3000; entry = 0x3040; present = 0x4000.

You will find lists of “magic” Direct3D vtable indices online. Take the number as a hint and derive it yourself, because the derivation takes a minute and tells you immediately when a list is talking about a different interface.

The method order follows the inherited interface layout: a vtable begins with the methods of the interfaces it inherits, in inheritance order, each in declaration order. IDXGISwapChain inherits like this:

slot 0QueryInterface
slot 1AddRef
slot 2Release
slot 3SetPrivateData
slot 4SetPrivateDataInterface
slot 5GetPrivateData
slot 6GetParent
slot 7GetDevice
slot 8Present
IUnknown
IDXGIObject
IDXGIDeviceSubObject
IDXGISwapChain
IDXGISwapChain's vtable. Each inherited interface contributes its methods in declaration order before Present gets its own slot.

3 + 4 + 1 = 8. Present is index 8, and now you know why, which means you can work out ResizeBuffers the same way instead of searching for it.

The same count applied to ID3D11DeviceContext gives IUnknown (3) plus ID3D11DeviceChild (4) = 7, then the context’s own methods in declaration order: VSSetConstantBuffers, PSSetShaderResources, PSSetShader, PSSetSamplers, VSSetShader, and then DrawIndexed at index 12.

Written out as slots:

7VSSetConstantBuffers
8PSSetShaderResources
9PSSetShader
10PSSetSamplers
11VSSetShader
12DrawIndexed
ID3D11DeviceContext's own methods start at slot 7, right after IUnknown (3 slots) and ID3D11DeviceChild (4 slots).

For the older API, IDirect3DDevice9 inherits only IUnknown, so its own methods begin at index 3 and are simply counted down the header: Present at 17, Reset at 16, EndScene at 42, DrawIndexedPrimitive at 82.

Interface Method Index Runs
IDirect3DDevice9 EndScene 42 once per frame, before presenting
IDirect3DDevice9 DrawIndexedPrimitive 82 once per indexed draw
IDXGISwapChain Present 8 once per frame
ID3D11DeviceContext DrawIndexed 12 once per indexed draw

Confirm any of these against the SDK header for the version you are targeting before relying on it. A number copied from a forum post may have been counted for a different interface or SDK version, and a hook installed at the wrong index replaces whichever method actually lives in that slot.

Find a vtable inside the process you control

Section titled “Find a vtable inside the process you control”

Here is a useful lab technique. To learn where Present lives in your own running program, create a small temporary swap chain inside that same process and inspect its vtable. You need not first find the main window’s swap chain.

Objects of one interface implementation commonly share a vtable. A temporary object therefore gives a candidate function address for that implementation. Confirm that the real swap chain in this process uses the same method entry before relying on it; another implementation may use a different table.

Create a small hidden window
Create a device and swap chain
Read the vtable pointer from the object
Copy slot 8
Release your objects
Candidate Present address
in this process

There are now three distinct ways to find code: a named export asks a DLL for a public function, a byte pattern searches for an instruction sequence, and this temporary COM object supplies the address of its own method. Only the last one tells us where this object’s Present implementation lives. It still requires a compatibility check against the actual object and runtime. Lesson 5.7 expands the byte-pattern case.

Do not copy that absolute pointer from a separate helper process into a game. Each process has its own virtual address space, and a graphics runtime or driver can expose a different implementation. To study a separate offline target, inspect its own swap chain or create the probe inside its process using the DLL lifecycle from Chapter 6. A module-relative address may be translated only after confirming the same module build and method body in both processes. Microsoft’s virtual-address documentation explains why identical numbers alone do not identify the same object.

The target here is your own program. Write a minimal Direct3D 11 program — a window, a device, a swap chain, a clear-and-present loop — and hook that. Every mistake is then yours to observe, and nothing depends on a specific game build.

The vtable commonly lives on a protected page: the program can read the slot, but a normal write fails. VirtualProtect temporarily makes the pointer-sized slot writable. Save both the original pointer and the previous protection, write the replacement, then restore the protection immediately. A shared vtable slot can affect every object using that table in this process, so the experiment must also restore the pointer when it ends:

// 🛡️ SAFETY: `slot` points into the vtable read from a live swap chain, the
// index was derived from the interface declaration, and the original pointer
// is stored so `Drop` can put it back.
unsafe {
let mut previous = PAGE_PROTECTION_FLAGS(0);
VirtualProtect(
slot.cast(),
size_of::<*const c_void>(),
PAGE_READWRITE,
&mut previous,
)?;
let original = *slot;
*slot = replacement;
VirtualProtect(slot.cast(), size_of::<*const c_void>(), previous, &mut previous)?;
original
}

Restore the previous protection rather than leaving the page writable. A page you widened stays widened for the life of the process. This short code shows the two protection changes and the pointer swap; a complete hook guard must also roll back if the second protection change fails. Lesson 8.3 applies the same verification and rollback reasoning to patched instruction bytes.

The replacement must match the ABI exactly

Section titled “The replacement must match the ABI exactly”

A COM method has a hidden first parameter — the interface pointer, the same this you met in Lesson 3.8 — and uses the system convention:

type PresentFn = unsafe extern "system" fn(
swap_chain: *mut c_void, // the hidden `this`
sync_interval: u32,
flags: u32,
) -> HRESULT;

Forget the first parameter and every argument shifts by one position. The call still runs, sync_interval reads whatever was in the this register, and the failure looks like a rendering glitch rather than a signature mistake.

Return the HRESULT you received from the original. Callers check it, and Present genuinely can fail — a lost device returns an error the game is expecting to handle.

The lifecycle is where hooks actually break

Section titled “The lifecycle is where hooks actually break”
slot holds the runtime's function
install (refuse if the slot
already holds your function)
restore (only if the slot
still holds your function)
a second install is refused
Original
Hooked

vtable_hook_lab.rs

rust-labs/src/bin/ · Rust, 245 lines

Show the codeHide the code
//! A portable model of the install / forward / restore lifecycle used when a
//! tool hooks a method through an object's function-pointer table.
//!
//! Direct3D reaches every method through a COM vtable, so a tool that wants to
//! observe `Present` or `DrawIndexed` replaces one entry in a table of
//! function pointers. The Windows details — page protection, calling
//! conventions, COM reference counts — are not modelled here. What is modelled
//! is the part that actually goes wrong: the bookkeeping around the swap.
//!
//! Nothing here loads a graphics API, touches another process, or leaves this
//! program. The "vtable" is an ordinary array of safe Rust function pointers.
//!
//! ```text
//! cargo run --bin vtable_hook_lab
//! cargo test --bin vtable_hook_lab
//! ```

use std::sync::Mutex;

/// One entry in the toy table. A real `Present` would carry a device pointer
/// and flags; the frame number stands in for all of that.
type PresentFn = fn(frame: u32) -> u32;

/// The toy interface. A COM vtable is a fixed array of function pointers, and
/// the object holds a pointer to it, so several objects of the same interface
/// share one table — which is exactly why a tool can read the layout from a
/// device it created itself.
#[derive(Debug)]
struct Vtable {
    slots: [PresentFn; SLOT_COUNT],
}

const SLOT_COUNT: usize = 4;
/// Chosen the way a real index is chosen: by counting methods in declaration
/// order, inherited ones first. See the lesson for the derivation.
const PRESENT_SLOT: usize = 2;

/// The implementation the graphics runtime would provide.
fn real_present(frame: u32) -> u32 {
    frame + 1
}

fn unused_slot(frame: u32) -> u32 {
    frame
}

/// The original pointer, saved so the replacement can forward to it.
static ORIGINAL_PRESENT: Mutex<Option<PresentFn>> = Mutex::new(None);
/// Frames the replacement observed, standing in for a telemetry sink.
static OBSERVED_FRAMES: Mutex<Vec<u32>> = Mutex::new(Vec::new());

/// The replacement. It records one small fact and forwards.
fn hooked_present(frame: u32) -> u32 {
    if let Ok(mut observed) = OBSERVED_FRAMES.lock() {
        observed.push(frame);
    }

    // Copy the pointer out and release the lock *before* calling through it.
    // Holding a lock across a forwarded call is how a hook deadlocks itself:
    // the original is free to re-enter the hooked path, and would then wait
    // for a lock this thread already holds.
    let original = ORIGINAL_PRESENT.lock().ok().and_then(|slot| *slot);

    match original {
        Some(original) => original(frame),
        // A replacement installed without a saved original cannot forward.
        // Returning a plausible value would hide the mistake.
        None => frame,
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum HookError {
    /// The slot already holds this replacement.
    AlreadyInstalled,
    /// The slot holds something that is not this replacement, so restoring
    /// would discard whatever is there now.
    SlotChanged,
    /// Nothing was installed.
    NotInstalled,
}

fn is_same_function(left: PresentFn, right: PresentFn) -> bool {
    std::ptr::fn_addr_eq(left, right)
}

/// Saves the original and writes the replacement into the slot.
fn install(vtable: &mut Vtable) -> Result<(), HookError> {
    let current = vtable.slots[PRESENT_SLOT];

    // Installing twice would save our own replacement as "the original", and
    // the replacement would then forward to itself forever. The stack overflow
    // happens on the next frame, far from the second install.
    if is_same_function(current, hooked_present) {
        return Err(HookError::AlreadyInstalled);
    }

    *ORIGINAL_PRESENT.lock().expect("lock is not poisoned") = Some(current);
    vtable.slots[PRESENT_SLOT] = hooked_present;
    Ok(())
}

/// Puts the original pointer back, but only if the slot is still ours.
fn restore(vtable: &mut Vtable) -> Result<(), HookError> {
    let mut saved = ORIGINAL_PRESENT.lock().expect("lock is not poisoned");
    let Some(original) = *saved else {
        return Err(HookError::NotInstalled);
    };

    // Someone installed after us. Writing our saved pointer now would erase
    // their replacement and leave them forwarding to a function no longer in
    // the table.
    if !is_same_function(vtable.slots[PRESENT_SLOT], hooked_present) {
        return Err(HookError::SlotChanged);
    }

    vtable.slots[PRESENT_SLOT] = original;
    *saved = None;
    Ok(())
}

fn fresh_vtable() -> Vtable {
    reset_lab_state();
    Vtable {
        slots: [unused_slot, unused_slot, real_present, unused_slot],
    }
}

fn reset_lab_state() {
    *ORIGINAL_PRESENT.lock().expect("lock is not poisoned") = None;
    OBSERVED_FRAMES
        .lock()
        .expect("lock is not poisoned")
        .clear();
}

fn observed_frames() -> Vec<u32> {
    OBSERVED_FRAMES
        .lock()
        .expect("lock is not poisoned")
        .clone()
}

/// Calls through the table the way the game's render loop would.
fn present_through_vtable(vtable: &Vtable, frame: u32) -> u32 {
    (vtable.slots[PRESENT_SLOT])(frame)
}

fn main() {
    let mut vtable = fresh_vtable();

    println!("1. Before installing");
    println!("   present(10) -> {}", present_through_vtable(&vtable, 10));
    println!("   observed frames: {:?}", observed_frames());

    println!("\n2. After installing");
    install(&mut vtable).expect("a fresh table accepts one install");
    println!("   present(10) -> {}", present_through_vtable(&vtable, 10));
    println!("   present(11) -> {}", present_through_vtable(&vtable, 11));
    println!("   observed frames: {:?}", observed_frames());
    println!("   the return value is unchanged, so the game sees no difference");

    println!("\n3. Installing twice is refused");
    println!("   second install: {:?}", install(&mut vtable));
    println!("   without that guard the replacement would forward to itself");

    println!("\n4. Another tool installs after us");
    let ours = vtable.slots[PRESENT_SLOT];
    vtable.slots[PRESENT_SLOT] = unused_slot; // stands in for a second hook
    println!("   restore now: {:?}", restore(&mut vtable));
    vtable.slots[PRESENT_SLOT] = ours;

    println!("\n5. Restoring cleanly");
    println!("   restore: {:?}", restore(&mut vtable));
    println!("   present(10) -> {}", present_through_vtable(&vtable, 10));
    println!("   restore again: {:?}", restore(&mut vtable));
}

#[cfg(test)]
mod tests {
    use super::{
        HookError, PRESENT_SLOT, fresh_vtable, install, is_same_function, observed_frames,
        present_through_vtable, real_present, restore, unused_slot,
    };

    #[test]
    fn a_hook_forwards_without_changing_the_result() {
        let mut vtable = fresh_vtable();
        let before = present_through_vtable(&vtable, 10);

        install(&mut vtable).expect("fresh table");
        let after = present_through_vtable(&vtable, 10);

        assert_eq!(before, after, "the game must observe the same behavior");
        assert_eq!(observed_frames(), vec![10]);
    }

    #[test]
    fn installing_twice_is_refused() {
        let mut vtable = fresh_vtable();
        install(&mut vtable).expect("fresh table");

        assert_eq!(install(&mut vtable), Err(HookError::AlreadyInstalled));

        // The saved original must still be the runtime's function, not ours.
        present_through_vtable(&vtable, 1);
        assert_eq!(observed_frames(), vec![1]);
    }

    #[test]
    fn restore_puts_the_exact_original_back() {
        let mut vtable = fresh_vtable();
        install(&mut vtable).expect("fresh table");
        restore(&mut vtable).expect("still ours");

        assert!(is_same_function(vtable.slots[PRESENT_SLOT], real_present));
        present_through_vtable(&vtable, 5);
        assert!(
            observed_frames().is_empty(),
            "a restored table must not reach the replacement"
        );
    }

    #[test]
    fn restore_refuses_to_clobber_a_later_hook() {
        let mut vtable = fresh_vtable();
        install(&mut vtable).expect("fresh table");

        vtable.slots[PRESENT_SLOT] = unused_slot; // a second tool installed
        assert_eq!(restore(&mut vtable), Err(HookError::SlotChanged));
        assert!(
            is_same_function(vtable.slots[PRESENT_SLOT], unused_slot),
            "the other tool's entry must survive"
        );
    }

    #[test]
    fn restoring_twice_reports_that_nothing_is_installed() {
        let mut vtable = fresh_vtable();
        install(&mut vtable).expect("fresh table");
        restore(&mut vtable).expect("still ours");

        assert_eq!(restore(&mut vtable), Err(HookError::NotInstalled));
    }
}

Install, forward, restore. Each step has a failure that a single successful test run will not reveal, so vtable_hook_lab.rs models the bookkeeping as plain safe Rust with no graphics API involved:

Terminal window
cd rust-labs
cargo run --bin vtable_hook_lab
cargo test --bin vtable_hook_lab

Installing twice saves your own replacement as “the original.” The replacement then forwards to itself, and the stack overflows on the next frame — far from the second install, which is what makes it confusing. Refuse the second install by checking whether the slot already holds your function.

Restoring blindly discards whoever installed after you. If another tool hooked the same slot later, writing your saved pointer back removes their replacement and leaves them forwarding to a function no longer in the table. Restore only when the slot still holds your own function.

Holding a lock across the forwarded call deadlocks — that is, two paths end up each waiting for something the other holds, and neither ever continues. The original is free to re-enter the hooked path. Copy the original pointer out, release the lock, then call — the lab does exactly this and says why in a comment.

The device can be recreated underneath you. A resolution change or an alt-tab causes ResizeBuffers, and in Direct3D 9 a lost device causes Reset. Cached textures, render targets, and fonts created against the old device are invalid afterwards. Treat those calls as the signal to drop and rebuild anything you allocated.

Present is not guaranteed to be your thread. Whatever the replacement touches is shared with the render thread, so it needs the discipline from Lesson 6.11: copy a small fixed-size sample, return, and analyze elsewhere. A lock, an allocation, or a file write on this path is a frame-time cost paid thousands of times per second.

What this technique does and does not tell you

Section titled “What this technique does and does not tell you”

Hooking Present tells you that a frame was submitted. It is the natural place to draw an overlay, count frames, or take a screenshot, and it is the mechanism behind capture software, frame-rate counters, debug HUDs, and graphics debuggers such as RenderDoc and PIX.

It tells you nothing about which objects are in that frame. For that you need the draw calls and the state bound around them, which is the next lesson. And as in Lesson 7.3, submission is not completion: Present returning does not mean the GPU has finished, and forcing it to catch up so you can measure something will change the thing you are measuring.

Keep this to a program you wrote or an offline single-player session on a version you have pinned. The transferable skill is COM interface dispatch and reversible hook lifecycle management, which is the same machinery used by capture tools, profilers, accessibility overlays, and the graphics debuggers above.

Knowledge check

Derive the slot

IDXGISwapChain inherits IUnknown (3 methods), then IDXGIObject (4 methods), then IDXGIDeviceSubObject (1 method), and declares Present first. Which vtable index is Present, and why does that reasoning matter?

Finished Lesson 7.10?

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.