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.