Introduction

hudhook is a Rust library for creating in-game overlays, similar to the Steam Overlay.

hudhook allows you to create injectable DLLs that seamlessly hook into the rendering loop of applications and draw a UI on top. This way, you can control the application's state, display relevant information, or do whatever it is you would like to do every time a frame is displayed by the rendering engine.

At the moment, the only UI toolkit supported is dear imgui, but there are plans to support egui in the future for a 100% Rust development experience.

hudhook currently supports rendering on top of DirectX 9, DirectX 11, DirectX 12, and OpenGL 3. If the application you chose to target uses one of these engines, hudhook can get in there and draw stuff for you!

The way this is done is by detouring calls to rendering functions, such as IDXGISwapChain::Present, and introduce custom logic and draw calls before yielding the control back to the host application.

In this book, you will be guided through creating a hookable library and injecting it into a sample DirectX 12 application.

Refer to the crate's documentation for the API.

Setting up the project

To create a library with hudhook, we need to setup our project as a Windows dynamically-linked library (DLL).

First of all, let's create a new Rust project and add hudhook as a dependency.

cargo init --lib hello-hud
cd hello-hud
cargo add hudhook@0.5 imgui@0.11

We need to specify that our library is a DLL, so let's add that to Cargo.toml:

[lib]
crate-type = ["cdylib", "rlib"]
name = "hello_hud"

We are now ready to start writing the code.

Writing the DLL code

We will build a DLL which tracks how much time has passed since it has been injected, and displays that in a simple dear imgui window.

We need a structure to store our state data. In our case, we only need to keep track of the moment the DLL was injected.

#![allow(unused)]
fn main() {
use std::time::Instant;

struct HelloHud {
    start_time: Instant,
}

impl HelloHud {
    fn new() -> Self {
        Self { start_time: Instant::now() }
    }
}
}

We then need to supply hudhook with the rendering code it is supposed to run at every frame. To do that, we import the ImguiRenderLoop trait, and implement that on our structure.

The trait consists of only one method, render. hudhook will supply the imgui::Ui object we need to use to render our UI, we are only tasked with actually implementing our rendering code.

#![allow(unused)]
fn main() {
use hudhook::hooks::ImguiRenderLoop;
use imgui::*;


impl ImguiRenderLoop for HelloHud {
    fn render(&mut self, ui: &mut Ui) {
        ui.window("##hello")
            .size([320., 200.], Condition::Always)
            .build(|| {
                ui.text("Hello, world!");
                ui.text(format!("Elapsed: {:?}", self.start_time.elapsed()));
            });
    }
}
}

That's it! Inside of the render method, we can deploy whatever logic and UI rendering we want.

Writing the entry point

In order for our code to be executed on injection, we need an entry point. In Windows, that entails implementing the DllMain method. We could roll our own, but hudhook provides facilities to simplify that to a single line of code.

The hudhook! macro takes in the type of the hook we are targeting, and an instance of a struct that implements the ImguiRenderLoop trait. We are targeting a DirectX 12 host application, so the target hook type is ImguiDx12Hooks.

Our HelloHud struct already implements ImguiRenderLoop, so we can instantiate it and use it as-is:

#![allow(unused)]
fn main() {
use hudhook::hooks::dx12::ImguiDX12Hooks;

hudhook::hudhook!(ImguiDx12Hooks, HelloHud::new());
}

We are finally ready to build our library.

cargo build --release

This will generate a target/release/hello_hud.dll. We can inject this library directly.

Roll your own DLL entry point

Some times, you may want to perform your own logic inside of the library's entry point, or use more than one kind of hook. Instead of relying on the entry point generation macro, you can write your own DllMain function and use the Hudhook builder object to build your hooks pipeline:

#![allow(unused)]
fn main() {
use hudhook::tracing::*;
use hudhook::*;

#[no_mangle]
pub unsafe extern "stdcall" fn DllMain(
    hmodule: HINSTANCE,
    reason: u32,
    _: *mut std::ffi::c_void,
) {
    if reason == DLL_PROCESS_ATTACH {
        trace!("DllMain()");
        std::thread::spawn(move || {
            if let Err(e) = Hudhook::builder()
                .with::<ImguiDx12Hooks>(HelloHud::new())
                .with_hmodule(hmodule)
                .build()
                .apply()
            {
                error!("Couldn't apply hooks: {e:?}");
                eject();
            }
        });
    }
}
}

Advanced

In this chapter, we will see a few techniques that are not specific to hudhook, but could be useful for the same use cases where hudhook would be a good fit.

Build a proxy DLL

Another injection technique that doesn't leverage an external injector is DLL proxying. This works by placing a properly named DLL on the DLL search path of your executable, so that when it invokes LoadLibrary, our own DLL is loaded instead.

For example, Dark Souls III loads dinput8.dll, and if we build a library with that file name and copy it next to DarkSoulsIII.exe, it will be loaded instead of the original dinput8.dll which usually sits in C:\Windows\System32.

The proxy DLL needs some attention as to the way it is compiled and linked.

Let's analyze the example of the Dark Souls III no-logo mod that is bundled with the practice tool.

Similarly to other hudhook libraries, we need to specify the type of library we are building in Cargo.toml:

[lib]
name = "dinput8"
crate-type = ["cdylib"]

We then need to specify the functions that will be exported from our library. Create a file named exports.def with the following contents:

EXPORTS
  DirectInput8Create

...and make this file visible to the linker via a build.rs build script:

fn main() {
    println!("cargo:rustc-cdylib-link-arg=/DEF:exports.def");
}

Our library will define its own DirectInput8Create export function, as specified above. In order to be invisible to the host application, we also need to make sure that when that function is invoked, we also invoke the original one.

#![allow(unused)]
fn main() {
// We define our exported function's signature as a type.
type FDirectInput8Create = unsafe extern "stdcall" fn(
    hinst: HINSTANCE,
    dwversion: u32,
    riidltf: *const GUID,
    ppvout: *mut *mut c_void,
    punkouter: HINSTANCE,
) -> HRESULT;

// We create a structure to hold a pointer to the original function.
struct State {
    directinput8create: FDirectInput8Create,
}

// These impls are safe because the pointer to the function will be constant
// across the entire execution.
unsafe impl Send for State {}
unsafe impl Sync for State {}

// We lazily initialize and statically store our `State` structure. The first
// time this is invoked, it will load the actual `dinput8.dll` and get the
// pointer to the `DirectInput8Create` function inside of it.
static STATE: LazyLock<State> = LazyLock::new(|| unsafe {
    let dinput8 = LoadLibraryA(PCSTR(b"C:\\Windows\\System32\\dinput8.dll\0".as_ptr())).unwrap();
    let directinput8create =
        std::mem::transmute(GetProcAddress(dinput8, PCSTR(b"DirectInput8Create\0".as_ptr())));
    println!("Called!");

    State { directinput8create }
});
}

We then need to define our exported function, paying attention that the calling convention is appropriate and making sure to invoke the original function with the same parameters, and return its return value.

#![allow(unused)]
fn main() {
#[no_mangle]
unsafe extern "stdcall" fn DirectInput8Create(
    hinst: HINSTANCE,
    dwversion: u32,
    riidltf: *const GUID,
    ppvout: *mut *mut c_void,
    punkouter: HINSTANCE,
) -> HRESULT {
    patch();  // Perform our custom logic, like setup hudhook or whatever.

    (STATE.directinput8create)(hinst, dwversion, riidltf, ppvout, punkouter)
}
}

Finally, define our entry point and make it so that the lazy lock we defined above is evaluated.

#![allow(unused)]
fn main() {
#[no_mangle]
#[allow(non_snake_case, unused_variables)]
extern "system" fn DllMain(dll_module: HINSTANCE, call_reason: u32, reserved: *mut c_void) -> BOOL {
    match call_reason {
        DLL_PROCESS_ATTACH => LazyLock::force(&STATE),
        DLL_PROCESS_DETACH => (),
        _ => (),
    }

    BOOL::from(true)
}
}

We can now compile our DLL:

cargo build --release

The result in target/release/dinput8.dll can be copied and pasted as-is and is going to execute whatever code is in the patch() function in the context of the process once the DLL gets automatically loaded at application startup.

Injecting a library

Let's now build an application that will inject the DLL into our target process.

First of all, let's download and compile the DirectX 12 samples. We will target the HelloTexture sample. Note that you may have to open the .sln file and retarget it if the build fails.

Invoke-WebRequest `
    https://github.com/microsoft/DirectX-Graphics-Samples/releases/download/MicrosoftDocs-Samples/d3d12-hello-world-samples-win32.zip `
    -OutFile d3d12-samples.zip

Expand-Archive -Path d3d12-samples.zip d3d12-samples
cd d3d12-samples\src\HelloTeture
msbuild -p:Platform=x64

Let's add a binary target to our project's Cargo.toml:

[[bin]]
name = "hello_injector"
path = "src/main.rs"

What our injector needs to do is find the process and inject the DLL. hudhook provides the facilities to do this in the hudhook::inject module.

The Process struct has two constructor methods: by_name and by_title. The former retrieves the process' ID by its name, the one you can see in the Task Manager, and that usually corresponds to the executable name. The latter finds the PID via matching against a window title. We will try both methods.

Injecting the DLL by process name:

use hudhook::inject::Process;

fn main() {
    Process::by_name("D3D12HelloTexture.exe").unwrap().inject("hello_hud.dll".into()).unwrap();
}

Injecting the DLL by window title:

use hudhook::inject::Process;

fn main() {
    Process::by_title("D3D12 Hello Texture").unwrap().inject("hello_hud.dll".into()).unwrap();
}

We can now compile the whole project. First, start up D3D12HelloTexture.exe, then run:

cargo build --release
cd target/release
./hello_injector.exe

Our dear imgui window will now show up inside the application's window.