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.
Cheatsheet for Lesson 7.10
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 areDrawIndexedandDraw.- vtable: A C++ object may begin with a pointer to a table of function addresses, called a vtable.
IDXGISwapChain: Button 8 on anIDXGISwapChainis labeledPresentby the interface contract, but the panel does not contain a universal street address for the code behind it.Present:Presentis index 8, and now you know why, which means you can work outResizeBuffersthe 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:
IDXGISwapChaininherits IUnknown (3 methods), then IDXGIObject (4 methods), then IDXGIDeviceSubObject (1 method), and declaresPresentfirst. Which vtable index isPresent, 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
The OpenGL method does not transfer
Section titled “The OpenGL method does not transfer”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 COM interface stores a table of methods
Section titled “A COM interface stores a table of methods”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 objectread [swap_chain] -> 0x0000_7FFA_1C30_5000 its vtablevtable + 8 * 8 -> 0x0000_7FFA_1C30_5040 the slot for method 8read that slot -> 0x0000_7FFA_1C28_9A10 the function that runsTwo 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.
1 Read 1: the first field is the vtable address
Swap-chain object0x000001F48A2000000x00007FFA1C305000first field: read 1other fieldsThe swap-chain object lives at 0x000001F48A200000. Reading its first field gives 0x00007FFA1C305000, the start of the vtable.
2 Slot 8 is at offset 0x40
vtable0x00007FFA1C305000slots 0–78 slots × 8 bytes = 0x40slot 80x00007FFA1C305040Eight slots of eight bytes put slot 8 at 0x00007FFA1C305000 + 0x40 = 0x00007FFA1C305040.
3 Read 2: slot 8 holds the Present code address
Slot 8, then the code it namesslot 8read 2Present implementation0x00007FFA1C289A10Reading 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 calledResult: 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
Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.
object = A.
Step 2, line 1. The two instances share this illustrative implementation table.
object = A; receiver = 0x1000; calls_A = 0; calls_B = 0.
Step 3, line 2. The first pointer supplies the table.
object = A; receiver = 0x1000; calls_A = 0; calls_B = 0; table = 0x3000.
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.
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.
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.
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.
Derive the index; do not memorize it
Section titled “Derive the index; do not memorize it”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:
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:
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.
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.
Do this to a program you wrote
Section titled “Do this to a program you wrote”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”vtable_hook_lab.rs
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:
cd rust-labscargo run --bin vtable_hook_labcargo test --bin vtable_hook_labInstalling 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.
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?
3 + 4 + 1 = 8. The value matters less than the method: counting inherited interfaces in order lets you derive ResizeBuffers, DrawIndexed, or any other slot yourself, and tells you at once when a list found online is describing a different interface. Slot width does change with pointer size, but the index does not.
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.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.