Pin zhook as a URL dependency instead of a relative path

build.zig.zon pointed at ../zhook, so a clone of this repo alone could not
build. Pin the Codeberg tarball for f1b252e (current zhook master) with its
hash; zig fetches it automatically. Verified by building with no sibling
zhook checkout present.
This commit is contained in:
MarcelineVQ
2026-07-27 22:05:03 -07:00
parent 1d41a029a8
commit 8773bb2821
6 changed files with 884 additions and 5 deletions
+4 -4
View File
@@ -274,14 +274,14 @@ what you do with them on someone else's server is between you and that server's
### Building
Requires Zig 0.16 and a checkout of [zhook](https://codeberg.org/marcelinevq/zhook)
as a sibling directory - `build.zig.zon` refers to it by relative path:
Requires Zig 0.16. The only dependency is
[zhook](https://codeberg.org/marcelinevq/zhook), the x86 inline hooking library,
pinned in `build.zig.zon` and fetched automatically:
```sh
git clone https://codeberg.org/marcelinevq/zhook
git clone https://codeberg.org/MarcelineVQ/WeirdUtils
cd WeirdUtils
zig build # zig-out/bin/weirdutils.dll
zig build # zig-out/bin/weirdutils.dll
zig build all-variants -Doptimize=ReleaseSmall # + one DLL per module
```
+2 -1
View File
@@ -4,7 +4,8 @@
.fingerprint = 0x54f0a9542562d318,
.dependencies = .{
.zhook = .{
.path = "../zhook",
.url = "https://codeberg.org/marcelinevq/zhook/archive/f1b252ed61ad839f00310c386761d068f293ad0f.tar.gz",
.hash = "zhook-0.1.0-pFkSYC6FAACAnkqu0k_DJBWdL0gJjrM22IfXeQPJAMov",
},
},
.paths = .{
@@ -0,0 +1,21 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// x86 length disassembler — standalone, no dependencies
const x86dis_mod = b.addModule("x86dis", .{
.root_source_file = b.path("src/x86dis.zig"),
.target = target,
.optimize = optimize,
});
// zhook — x86-32 inline hooking with auto-sizing trampolines
const zhook_mod = b.addModule("zhook", .{
.root_source_file = b.path("src/zhook.zig"),
.target = target,
.optimize = optimize,
});
zhook_mod.addImport("x86dis", x86dis_mod);
}
@@ -0,0 +1,10 @@
.{
.name = .zhook,
.version = "0.1.0",
.fingerprint = 0xf05c933a601259a4,
.paths = .{
"build.zig",
"build.zig.zon",
"src",
},
}
@@ -0,0 +1,432 @@
//! Minimal x86 (32-bit) length disassembler.
//!
//! Faithful port of Vyacheslav Patkov's Hacker Disassembler Engine 32 (HDE32)
//! Only computes instruction length + flags needed for relocation (F_RELATIVE).
//! ~470 bytes of table data, compiles to ~1-2 KB.
//!
//! Standalone module with no OS dependencies — pure computation on byte slices.
//! Can be used independently of the hook library for any x86 disassembly task.
const std = @import("std");
// ── public flags ───────────────────────────────────────────────────────
/// Instruction has a ModR/M byte.
pub const F_MODRM: u32 = 0x00000001;
/// Instruction has a SIB (Scale-Index-Base) byte.
pub const F_SIB: u32 = 0x00000002;
/// Instruction has an 8-bit immediate operand.
pub const F_IMM8: u32 = 0x00000004;
/// Instruction has a 16-bit immediate operand.
pub const F_IMM16: u32 = 0x00000008;
/// Instruction has a 32-bit immediate operand.
pub const F_IMM32: u32 = 0x00000010;
/// Instruction has an 8-bit displacement.
pub const F_DISP8: u32 = 0x00000020;
/// Instruction has a 16-bit displacement.
pub const F_DISP16: u32 = 0x00000040;
/// Instruction has a 32-bit displacement.
pub const F_DISP32: u32 = 0x00000080;
/// Instruction contains a relative offset (CALL/JMP/Jcc). Indicates the
/// immediate is PC-relative and must be relocated if the instruction is moved.
pub const F_RELATIVE: u32 = 0x00000100;
/// Decoding failed — the byte sequence is not a valid x86 instruction.
pub const F_ERROR: u32 = 0x00001000;
// ── internal cflags ────────────────────────────────────────────────────
const C_MODRM: u8 = 0x01;
const C_IMM8: u8 = 0x02;
const C_IMM16: u8 = 0x04;
const C_IMM_P66: u8 = 0x10;
const C_REL8: u8 = 0x20;
const C_REL32: u8 = 0x40;
const C_GROUP: u8 = 0x80;
const C_ERROR: u8 = 0xff;
const PRE_NONE: u8 = 0x01;
const PRE_66: u8 = 0x08;
const PRE_67: u8 = 0x10;
const DELTA_OPCODES: usize = 0x4a;
// HDE32 opcode table — verbatim from table32.h
const hde32_table = [_]u8{
0xa3, 0xa8, 0xa3, 0xa8, 0xa3, 0xa8, 0xa3, 0xa8, 0xa3, 0xa8, 0xa3, 0xa8, 0xa3, 0xa8, 0xa3,
0xa8, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xac, 0xaa, 0xb2, 0xaa, 0x9f, 0x9f,
0x9f, 0x9f, 0xb5, 0xa3, 0xa3, 0xa4, 0xaa, 0xaa, 0xba, 0xaa, 0x96, 0xaa, 0xa8, 0xaa, 0xc3,
0xc3, 0x96, 0x96, 0xb7, 0xae, 0xd6, 0xbd, 0xa3, 0xc5, 0xa3, 0xa3, 0x9f, 0xc3, 0x9c, 0xaa,
0xaa, 0xac, 0xaa, 0xbf, 0x03, 0x7f, 0x11, 0x7f, 0x01, 0x7f, 0x01, 0x3f, 0x01, 0x01, 0x90,
0x82, 0x7d, 0x97, 0x59, 0x59, 0x59, 0x59, 0x59, 0x7f, 0x59, 0x59, 0x60, 0x7d, 0x7f, 0x7f,
0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x9a, 0x88, 0x7d,
0x59, 0x50, 0x50, 0x50, 0x50, 0x59, 0x59, 0x59, 0x59, 0x61, 0x94, 0x61, 0x9e, 0x59, 0x59,
0x85, 0x59, 0x92, 0xa3, 0x60, 0x60, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59,
0x59, 0x59, 0x9f, 0x01, 0x03, 0x01, 0x04, 0x03, 0xd5, 0x03, 0xcc, 0x01, 0xbc, 0x03, 0xf0,
0x10, 0x10, 0x10, 0x10, 0x50, 0x50, 0x50, 0x50, 0x14, 0x20, 0x20, 0x20, 0x20, 0x01, 0x01,
0x01, 0x01, 0xc4, 0x02, 0x10, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0xc0, 0xc2, 0x10, 0x11,
0x02, 0x03, 0x11, 0x03, 0x03, 0x04, 0x00, 0x00, 0x14, 0x00, 0x02, 0x00, 0x00, 0xc6, 0xc8,
0x02, 0x02, 0x02, 0x02, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0xff, 0xca,
0x01, 0x01, 0x01, 0x00, 0x06, 0x00, 0x04, 0x00, 0xc0, 0xc2, 0x01, 0x01, 0x03, 0x01, 0xff,
0xff, 0x01, 0x00, 0x03, 0xc4, 0xc4, 0xc6, 0x03, 0x01, 0x01, 0x01, 0xff, 0x03, 0x03, 0x03,
0xc8, 0x40, 0x00, 0x0a, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x7f, 0x00, 0x33, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xbf, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00,
0x00, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x7f, 0x00, 0x00, 0xff, 0x4a, 0x4a, 0x4a, 0x4a, 0x4b, 0x52, 0x4a, 0x4a, 0x4a, 0x4a, 0x4f,
0x4c, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x55, 0x45, 0x40, 0x4a, 0x4a, 0x4a,
0x45, 0x59, 0x4d, 0x46, 0x4a, 0x5d, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a,
0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x61, 0x63, 0x67, 0x4e, 0x4a, 0x4a, 0x6b, 0x6d, 0x4a, 0x4a,
0x45, 0x6d, 0x4a, 0x4a, 0x44, 0x45, 0x4a, 0x4a, 0x00, 0x00, 0x00, 0x02, 0x0d, 0x06, 0x06,
0x06, 0x06, 0x0e, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 0x06, 0x00, 0x06, 0x06, 0x02, 0x06,
0x00, 0x0a, 0x0a, 0x07, 0x07, 0x06, 0x02, 0x05, 0x05, 0x02, 0x02, 0x00, 0x00, 0x04, 0x04,
0x04, 0x04, 0x00, 0x00, 0x00, 0x0e, 0x05, 0x06, 0x06, 0x06, 0x01, 0x06, 0x00, 0x00, 0x08,
0x00, 0x10, 0x00, 0x18, 0x00, 0x20, 0x00, 0x28, 0x00, 0x30, 0x00, 0x80, 0x01, 0x82, 0x01,
0x86, 0x00, 0xf6, 0xcf, 0xfe, 0x3f, 0xab, 0x00, 0xb0, 0x00, 0xb1, 0x00, 0xb3, 0x00, 0xba,
0xf8, 0xbb, 0x00, 0xc0, 0x00, 0xc1, 0x00, 0xc7, 0xbf, 0x62, 0xff, 0x00, 0x8d, 0xff, 0x00,
0xc4, 0xff, 0x00, 0xc5, 0xff, 0x00,
};
/// Decoded instruction descriptor. Contains the length and metadata flags
/// needed for instruction relocation — no operand values are extracted.
pub const Insn = struct {
/// Total instruction length in bytes (including prefixes, opcode, ModRM, SIB, disp, imm).
len: u8,
/// Bitmask of `F_*` flags describing the instruction's encoding.
flags: u32,
/// Primary opcode byte (after any prefixes). For two-byte opcodes (0F xx), this is 0x0F.
opcode: u8,
/// Secondary opcode byte for two-byte instructions (the byte after 0F). Zero for single-byte opcodes.
opcode2: u8,
};
/// Decode the instruction at `code`, returning its length and flags.
///
/// Handles all x86-32 prefixes, one- and two-byte opcodes, ModRM/SIB,
/// displacement, and immediate fields. Sets `F_RELATIVE` on instructions
/// with PC-relative operands (E8 CALL, E9 JMP, 0F 8x Jcc, short jumps).
///
/// Does not read beyond the instruction boundary — safe to use on
/// pointers to live code without over-reading into adjacent instructions.
/// Returns `F_ERROR` in flags if the byte sequence is invalid; `len` is
/// clamped to 15 (the x86 maximum instruction length).
pub fn decode(code: [*]const u8) Insn {
var result = Insn{ .len = 0, .flags = 0, .opcode = 0, .opcode2 = 0 };
var p: usize = 0;
var pref: u8 = 0;
var disp_size: u8 = 0;
// ── prefixes ──
var prefix_count: u8 = 16;
prefix_loop: while (prefix_count > 0) : (prefix_count -= 1) {
switch (code[p]) {
0xf3, 0xf2 => pref |= if (code[p] == 0xf3) 0x04 else 0x02,
0xf0 => pref |= 0x20, // PRE_LOCK
0x26, 0x2e, 0x36, 0x3e, 0x64, 0x65 => pref |= 0x40, // PRE_SEG
0x66 => pref |= PRE_66,
0x67 => pref |= PRE_67,
else => break :prefix_loop,
}
p += 1;
}
result.flags = @as(u32, pref) << 23;
if (pref == 0) pref |= PRE_NONE;
// ── opcode ──
var ht_base: usize = 0;
var c = code[p];
p += 1;
result.opcode = c;
if (c == 0x0f) {
// two-byte opcode
result.opcode2 = code[p];
c = code[p];
p += 1;
ht_base = DELTA_OPCODES;
} else if (c >= 0xa0 and c <= 0xa3) {
// MOV moffs — address-size prefix swaps operand-size behavior
if (pref & PRE_67 != 0)
pref |= PRE_66
else
pref &= ~PRE_66;
}
const opcode = c;
// ── two-level table lookup: ht[ht[opcode/4] + (opcode%4)] ──
var cflags: u8 = blk: {
const idx1 = ht_base + @as(usize, opcode / 4);
if (idx1 >= hde32_table.len) break :blk C_ERROR;
const idx2 = ht_base + @as(usize, hde32_table[idx1]) + @as(usize, opcode % 4);
if (idx2 >= hde32_table.len) break :blk C_ERROR;
break :blk hde32_table[idx2];
};
if (cflags == C_ERROR) {
result.flags |= F_ERROR;
cflags = 0;
if ((opcode & 0xfd) == 0x24) // (opcode & -3) == 0x24
cflags +%= 1;
}
// ── group resolution ──
var x: u8 = 0;
if (cflags & C_GROUP != 0) {
const group_idx = ht_base + @as(usize, cflags & 0x7f);
if (group_idx + 1 < hde32_table.len) {
const t = std.mem.readInt(u16, hde32_table[group_idx..][0..2], .little);
cflags = @truncate(t);
x = @truncate(t >> 8);
}
}
// ── modrm ──
if (cflags & C_MODRM != 0) {
result.flags |= F_MODRM;
const modrm = code[p];
p += 1;
const m_mod = modrm >> 6;
const m_rm: u8 = modrm & 7;
const m_reg: u3 = @truncate((modrm & 0x3f) >> 3);
// F6 TEST imm8 / F7 TEST imm16/32
if (m_reg <= 1) {
if (opcode == 0xf6)
cflags |= C_IMM8;
if (opcode == 0xf7)
cflags |= C_IMM_P66;
}
// displacement
switch (m_mod) {
0 => {
if (pref & PRE_67 != 0) {
if (m_rm == 6) disp_size = 2;
} else {
if (m_rm == 5) disp_size = 4;
}
},
1 => disp_size = 1,
2 => {
disp_size = 2;
if (pref & PRE_67 == 0)
disp_size = 4;
},
else => {},
}
// SIB byte
if (m_mod != 3 and m_rm == 4 and (pref & PRE_67 == 0)) {
result.flags |= F_SIB;
const sib = code[p];
p += 1;
if ((sib & 7) == 5 and (m_mod & 1) == 0)
disp_size = 4;
}
// displacement bytes
switch (disp_size) {
1 => {
result.flags |= F_DISP8;
p += 1;
},
2 => {
result.flags |= F_DISP16;
p += 2;
},
4 => {
result.flags |= F_DISP32;
p += 4;
},
else => {},
}
}
// ── immediates ──
if (cflags & C_IMM_P66 != 0) {
if (cflags & C_REL32 != 0) {
if (pref & PRE_66 != 0) {
result.flags |= F_IMM16 | F_RELATIVE;
p += 2;
// disasm_done — skip remaining immediate checks
result.len = @intCast(p);
if (result.len > 15) {
result.flags |= F_ERROR;
result.len = 15;
}
return result;
}
// fall through to rel32_ok below
} else {
if (pref & PRE_66 != 0) {
result.flags |= F_IMM16;
p += 2;
} else {
result.flags |= F_IMM32;
p += 4;
}
}
}
if (cflags & C_IMM16 != 0) {
if (result.flags & F_IMM32 != 0) {
result.flags |= F_IMM16;
} else if (result.flags & F_IMM16 != 0) {
// F_2IMM16
} else {
result.flags |= F_IMM16;
}
p += 2;
}
if (cflags & C_IMM8 != 0) {
result.flags |= F_IMM8;
p += 1;
}
if (cflags & C_REL32 != 0) {
result.flags |= F_IMM32 | F_RELATIVE;
p += 4;
} else if (cflags & C_REL8 != 0) {
result.flags |= F_IMM8 | F_RELATIVE;
p += 1;
}
result.len = @intCast(p);
if (result.len > 15) {
result.flags |= F_ERROR;
result.len = 15;
}
return result;
}
// ── tests ──────────────────────────────────────────────────────────────
test "push ebp" {
const d = decode(&[_]u8{ 0x55, 0xCC });
try std.testing.expectEqual(@as(u8, 1), d.len);
}
test "mov ebp, esp" {
// 8B EC (or 89 E5)
const d = decode(&[_]u8{ 0x8B, 0xEC });
try std.testing.expectEqual(@as(u8, 2), d.len);
try std.testing.expect(d.flags & F_MODRM != 0);
}
test "call rel32" {
const d = decode(&[_]u8{ 0xE8, 0x78, 0x56, 0x34, 0x12 });
try std.testing.expectEqual(@as(u8, 5), d.len);
try std.testing.expect(d.flags & F_RELATIVE != 0);
try std.testing.expect(d.flags & F_IMM32 != 0);
}
test "jmp rel32" {
const d = decode(&[_]u8{ 0xE9, 0x00, 0x00, 0x00, 0x00 });
try std.testing.expectEqual(@as(u8, 5), d.len);
try std.testing.expect(d.flags & F_RELATIVE != 0);
}
test "sub esp, imm8" {
// 83 EC 10
const d = decode(&[_]u8{ 0x83, 0xEC, 0x10 });
try std.testing.expectEqual(@as(u8, 3), d.len);
try std.testing.expect(d.flags & F_MODRM != 0);
try std.testing.expect(d.flags & F_IMM8 != 0);
}
test "mov eax, [ebp+8]" {
// 8B 45 08
const d = decode(&[_]u8{ 0x8B, 0x45, 0x08 });
try std.testing.expectEqual(@as(u8, 3), d.len);
try std.testing.expect(d.flags & F_MODRM != 0);
try std.testing.expect(d.flags & F_DISP8 != 0);
}
test "jz rel32 (0F 84)" {
const d = decode(&[_]u8{ 0x0F, 0x84, 0x10, 0x00, 0x00, 0x00 });
try std.testing.expectEqual(@as(u8, 6), d.len);
try std.testing.expect(d.flags & F_RELATIVE != 0);
}
test "nop" {
const d = decode(&[_]u8{0x90});
try std.testing.expectEqual(@as(u8, 1), d.len);
}
test "ret" {
const d = decode(&[_]u8{0xC3});
try std.testing.expectEqual(@as(u8, 1), d.len);
}
test "short jmp EB" {
const d = decode(&[_]u8{ 0xEB, 0x05 });
try std.testing.expectEqual(@as(u8, 2), d.len);
try std.testing.expect(d.flags & F_RELATIVE != 0);
try std.testing.expect(d.flags & F_IMM8 != 0);
}
test "short jcc 74 (jz rel8)" {
const d = decode(&[_]u8{ 0x74, 0x0A });
try std.testing.expectEqual(@as(u8, 2), d.len);
try std.testing.expect(d.flags & F_RELATIVE != 0);
}
test "mov eax, imm32" {
const d = decode(&[_]u8{ 0xB8, 0x44, 0x33, 0x22, 0x11 });
try std.testing.expectEqual(@as(u8, 5), d.len);
}
test "push imm32" {
const d = decode(&[_]u8{ 0x68, 0x44, 0x33, 0x22, 0x11 });
try std.testing.expectEqual(@as(u8, 5), d.len);
}
test "push imm8" {
// 6A 01
const d = decode(&[_]u8{ 0x6A, 0x01 });
try std.testing.expectEqual(@as(u8, 2), d.len);
}
test "mov [ebp-4], eax" {
// 89 45 FC
const d = decode(&[_]u8{ 0x89, 0x45, 0xFC });
try std.testing.expectEqual(@as(u8, 3), d.len);
try std.testing.expect(d.flags & F_MODRM != 0);
try std.testing.expect(d.flags & F_DISP8 != 0);
}
test "lea eax, [ecx+edx*4+8]" {
// 8D 44 91 08
const d = decode(&[_]u8{ 0x8D, 0x44, 0x91, 0x08 });
try std.testing.expectEqual(@as(u8, 4), d.len);
try std.testing.expect(d.flags & F_MODRM != 0);
try std.testing.expect(d.flags & F_SIB != 0);
try std.testing.expect(d.flags & F_DISP8 != 0);
}
test "mov [disp32], eax" {
// A3 xx xx xx xx
const d = decode(&[_]u8{ 0xA3, 0x00, 0x10, 0x40, 0x00 });
try std.testing.expectEqual(@as(u8, 5), d.len);
}
test "sub esp, imm32" {
// 81 EC 00 01 00 00
const d = decode(&[_]u8{ 0x81, 0xEC, 0x00, 0x01, 0x00, 0x00 });
try std.testing.expectEqual(@as(u8, 6), d.len);
try std.testing.expect(d.flags & F_MODRM != 0);
}
test "test eax, imm32 (F7 C0)" {
// F7 C0 FF 00 00 00 = test eax, 0xFF
const d = decode(&[_]u8{ 0xF7, 0xC0, 0xFF, 0x00, 0x00, 0x00 });
try std.testing.expectEqual(@as(u8, 6), d.len);
}
test "ret imm16" {
// C2 04 00
const d = decode(&[_]u8{ 0xC2, 0x04, 0x00 });
try std.testing.expectEqual(@as(u8, 3), d.len);
}
@@ -0,0 +1,415 @@
//! x86-32 inline hooking library for Windows DLL injection.
//!
//! Provides auto-sizing detours with instruction relocation, type-safe
//! `Detour(FnType)` wrappers, memory read/write helpers, rel32 arithmetic,
//! a generic `fastcall` caller, and Windows virtual memory API re-exports.
//!
//! ## Type-safe API (Detour) — recommended
//!
//! ```zig
//! const fc: std.builtin.CallingConvention = .{ .x86_fastcall = .{} };
//! const CheckFile = fn (u32, u32, u32) callconv(fc) u32;
//! var hook: Detour(CheckFile) = .{};
//! hook.attach(0x654DD0, &myDetour);
//! // Inside detour: hook.callOriginal(.{ filename, flags, output });
//! hook.detach();
//! ```
//!
//! ## Low-level API (GenericHook)
//!
//! ```zig
//! var my_hook: GenericHook = .{};
//! if (my_hook.install(0x401000, @intFromPtr(&myDetour)) == .ok) {
//! const orig = my_hook.getTrampoline(OrigFnType);
//! _ = orig();
//! }
//! my_hook.remove();
//! ```
const std = @import("std");
const x86dis = @import("x86dis");
// =============================================================================
// Windows API
// =============================================================================
const WINAPI = std.builtin.CallingConvention.winapi;
/// Memory protection constant: page is readable, writable, and executable.
pub const PAGE_EXECUTE_READWRITE: u32 = 0x40;
/// Memory allocation type: commit physical storage for the region.
pub const MEM_COMMIT: u32 = 0x1000;
/// Memory free type: release the region (decommit + free address space).
pub const MEM_RELEASE: u32 = 0x8000;
extern "kernel32" fn VirtualProtect(
lpAddress: *anyopaque,
dwSize: usize,
flNewProtect: u32,
lpflOldProtect: *u32,
) callconv(WINAPI) i32;
/// Allocate or reserve virtual memory. Used to create RWX trampoline pages.
pub extern "kernel32" fn VirtualAlloc(
lpAddress: ?*anyopaque,
dwSize: usize,
flAllocationType: u32,
flProtect: u32,
) callconv(WINAPI) ?[*]u8;
/// Release virtual memory previously allocated with `VirtualAlloc`.
pub extern "kernel32" fn VirtualFree(
lpAddress: *anyopaque,
dwSize: usize,
dwFreeType: u32,
) callconv(WINAPI) i32;
// =============================================================================
// Memory helpers
// =============================================================================
/// Read a value of type `T` from an arbitrary memory address (unaligned).
pub fn readMem(comptime T: type, addr: usize) T {
return @as(*align(1) const T, @ptrFromInt(addr)).*;
}
/// Write raw bytes to an arbitrary memory address. No protection change —
/// caller must ensure the page is writable (or use `writeProtected`).
pub fn writeMem(addr: usize, bytes: []const u8) void {
const dest: [*]u8 = @ptrFromInt(addr);
for (bytes, 0..) |b, i| {
dest[i] = b;
}
}
/// Write bytes to a potentially read-only/executable page. Temporarily sets
/// PAGE_EXECUTE_READWRITE, writes, then restores the original protection.
pub fn writeProtected(addr: usize, bytes: []const u8) void {
var old: u32 = 0;
_ = VirtualProtect(@ptrFromInt(addr), bytes.len, PAGE_EXECUTE_READWRITE, &old);
writeMem(addr, bytes);
_ = VirtualProtect(@ptrFromInt(addr), bytes.len, old, &old);
}
// =============================================================================
// Rel32 helpers
// =============================================================================
/// Resolve the absolute target of an E8 (CALL) or E9 (JMP) at `addr`.
/// Reads the signed rel32 operand at addr+1 and computes addr+5+offset.
pub fn rel32Target(addr: usize) usize {
const offset: u32 = @bitCast(@as(*align(1) const i32, @ptrFromInt(addr + 1)).*);
return (addr + 5) +% offset;
}
/// Write a rel32 displacement into dest[0..4] such that a JMP/CALL
/// from address `from` reaches `to`. Displacement = to - (from + 4).
pub fn writeRel32(dest: [*]u8, from: usize, to: usize) void {
std.mem.writeInt(u32, dest[0..4], to -% (from + 4), .little);
}
// =============================================================================
// Calling convention aliases and call helper
// =============================================================================
/// x86-32 calling convention shorthands for use with `callconv()`.
///
/// Once Zig supports `@Type` for function types, these can be used to build
/// convention-specific wrappers (stdcall/thiscall/fastcall/cdecl) that inject
/// the callconv automatically, removing the need for `callconv(hook.cc.*)`.
///
/// ```zig
/// const result = hook.call(fn (u32, u32) callconv(hook.cc.thiscall) i32, 0x6061E0, .{ player, unit });
/// ```
pub const cc = struct {
pub const stdcall: std.builtin.CallingConvention = .{ .x86_stdcall = .{} };
pub const thiscall: std.builtin.CallingConvention = .{ .x86_thiscall = .{} };
pub const fastcall: std.builtin.CallingConvention = .{ .x86_fastcall = .{} };
pub const cdecl: std.builtin.CallingConvention = .c;
};
/// Call a function at `addr` using a typed function pointer cast.
///
/// The function type `F` must include an explicit `callconv`. Supports all
/// x86-32 conventions (stdcall, thiscall, fastcall, cdecl). Uses `.never_tail`
/// to prevent tail-call optimization that would corrupt callee-cleanup stacks.
///
/// ```zig
/// const result = hook.call(fn (u32, u32) callconv(hook.cc.stdcall) u32, 0x464870, .{ lo, hi });
/// ```
pub fn call(comptime F: type, addr: usize, args: anytype) FnReturnType(F) {
const func: *const F = @ptrFromInt(addr);
return @call(.never_tail, func, args);
}
fn FnReturnType(comptime F: type) type {
return @typeInfo(F).@"fn".return_type orelse void;
}
// ═══════════════════════════════════════════════════════════════════════
// GenericHook — low-level auto-sizing hook
// ═══════════════════════════════════════════════════════════════════════
const JMP_SIZE: usize = 5; // E9 + rel32
const MAX_STOLEN: usize = 32;
const ALLOC_SIZE: usize = 64; // trampoline only
/// Auto-sizing inline hook that uses the x86 length disassembler to determine
/// how many prologue bytes to steal, then copies and relocates them into a
/// trampoline. Handles hook chaining (detects an existing E9 JMP at the target)
/// and expands short jumps to near jumps during relocation.
///
/// For type-safe hooking with automatic calling convention handling, use
/// `Detour(FnType)` instead — it wraps `GenericHook` and adds compile-time
/// type checking.
pub const GenericHook = struct {
/// Base of the allocated RWX page, or null if not yet prepared.
mem: ?[*]u8 = null,
/// Address of the trampoline entry point (calls the original code).
trampoline: usize = 0,
/// Address of the hooked function's entry point.
target: usize = 0,
/// Number of bytes stolen from the target prologue (>= 5 for E9 JMP).
stolen_size: usize = 0,
/// Original prologue bytes, saved for restoration on `remove()`.
saved_bytes: [MAX_STOLEN]u8 = undefined,
/// Result of a hook operation.
pub const Error = enum {
/// Success.
ok,
/// `VirtualAlloc` failed to allocate executable memory.
alloc_failed,
/// The length disassembler returned `F_ERROR` or a zero-length instruction.
disasm_error,
/// Could not steal enough bytes for a 5-byte JMP before hitting `MAX_STOLEN`.
prologue_too_short,
/// Prologue contains a relative instruction that cannot be relocated
/// (e.g. LOOP, JECXZ — only CALL/JMP/Jcc are supported).
unsupported_relocation,
};
/// Convenience: `prepare` + `activate` in one call.
pub fn install(self: *GenericHook, target: usize, detour_addr: usize) Error {
const err = self.prepare(target);
if (err != .ok) return err;
self.activate(detour_addr);
return .ok;
}
/// Phase 1: disassemble prologue, allocate trampoline, copy + relocate.
///
/// Walks the target's prologue with `x86dis.decode` until at least 5 bytes
/// are covered, allocates an RWX page, copies the stolen bytes into a
/// trampoline, relocates any relative instructions (E8/E9/Jcc/short jumps),
/// and appends a JMP back to the remainder of the original function.
///
/// If the target already starts with an E9 JMP (another hook), chains
/// through it: the trampoline jumps to the existing detour rather than
/// copying the prologue.
///
/// Does NOT patch the target — call `activate()` after to write the JMP.
pub fn prepare(self: *GenericHook, target: usize) Error {
if (self.mem != null) return .ok;
const src: [*]const u8 = @ptrFromInt(target);
// ── determine how many bytes to steal ──
var stolen: usize = 0;
while (stolen < JMP_SIZE) {
const insn = x86dis.decode(src + stolen);
if (insn.flags & x86dis.F_ERROR != 0) return .disasm_error;
if (insn.len == 0) return .disasm_error;
stolen += insn.len;
if (stolen > MAX_STOLEN) return .prologue_too_short;
}
// ── allocate ──
const mem = VirtualAlloc(null, ALLOC_SIZE, MEM_COMMIT, PAGE_EXECUTE_READWRITE) orelse return .alloc_failed;
self.mem = mem;
self.target = target;
self.stolen_size = stolen;
self.trampoline = @intFromPtr(mem);
@memcpy(self.saved_bytes[0..stolen], src[0..stolen]);
// ── check if already hooked (E9 at target) — chain through ──
if (src[0] == 0xE9) {
const other_detour = rel32Target(target);
mem[0] = 0xE9;
writeRel32(mem + 1, self.trampoline + 1, other_detour);
return .ok;
}
// ── build trampoline: copy + relocate ──
var t_pos: usize = 0;
var s_pos: usize = 0;
while (s_pos < stolen) {
const insn = x86dis.decode(src + s_pos);
const op = insn.opcode;
const src_addr = target + s_pos;
const dst_addr = self.trampoline + t_pos;
if (insn.flags & x86dis.F_RELATIVE != 0) {
if (op == 0xE8 or op == 0xE9) {
// CALL/JMP rel32
const abs = rel32Target(src_addr);
mem[t_pos] = op;
writeRel32(mem + t_pos + 1, dst_addr + 1, abs);
t_pos += 5;
} else if (op == 0x0F and insn.opcode2 >= 0x80 and insn.opcode2 <= 0x8F) {
// Jcc rel32 (0F 80-8F)
const abs = jcc32Target(src_addr);
mem[t_pos] = 0x0F;
mem[t_pos + 1] = insn.opcode2;
writeRel32(mem + t_pos + 2, dst_addr + 2, abs);
t_pos += 6;
} else if (op >= 0x70 and op <= 0x7F) {
// Short Jcc → expand to near Jcc (0F 8x)
const offset = @as(i8, @bitCast(src[s_pos + 1]));
const abs: usize = @bitCast(@as(isize, @intCast(src_addr + 2)) + offset);
mem[t_pos] = 0x0F;
mem[t_pos + 1] = op + 0x10;
writeRel32(mem + t_pos + 2, dst_addr + 2, abs);
t_pos += 6;
} else if (op == 0xEB) {
// Short JMP → expand to near JMP (E9)
const offset = @as(i8, @bitCast(src[s_pos + 1]));
const abs: usize = @bitCast(@as(isize, @intCast(src_addr + 2)) + offset);
mem[t_pos] = 0xE9;
writeRel32(mem + t_pos + 1, dst_addr + 1, abs);
t_pos += 5;
} else {
// LOOP/JECXZ or unknown — cannot trivially expand
self.cleanup();
return .unsupported_relocation;
}
} else {
// Non-relative — copy verbatim
@memcpy(mem[t_pos .. t_pos + insn.len], src[s_pos .. s_pos + insn.len]);
t_pos += insn.len;
}
s_pos += insn.len;
}
// ── JMP back to original code after stolen bytes ──
mem[t_pos] = 0xE9;
writeRel32(
mem + t_pos + 1,
self.trampoline + t_pos + 1,
target + stolen,
);
return .ok;
}
/// Phase 2: write the E9 JMP patch at the target.
/// Any excess stolen bytes beyond the 5-byte JMP are filled with NOPs.
pub fn activate(self: *GenericHook, detour_addr: usize) void {
var patch: [MAX_STOLEN]u8 = .{0x90} ** MAX_STOLEN;
patch[0] = 0xE9;
writeRel32(patch[1..5], self.target + 1, detour_addr);
writeProtected(self.target, patch[0..self.stolen_size]);
}
/// Restore original bytes and free trampoline memory.
pub fn remove(self: *GenericHook) void {
if (self.mem == null) return;
writeProtected(self.target, self.saved_bytes[0..self.stolen_size]);
_ = VirtualFree(@ptrFromInt(@intFromPtr(self.mem.?)), 0, MEM_RELEASE);
self.mem = null;
}
/// Get trampoline as a typed function pointer.
pub fn getTrampoline(self: *const GenericHook, comptime T: type) T {
return @ptrFromInt(self.trampoline);
}
fn cleanup(self: *GenericHook) void {
if (self.mem) |m| {
_ = VirtualFree(@ptrFromInt(@intFromPtr(m)), 0, MEM_RELEASE);
self.mem = null;
}
}
};
// ═══════════════════════════════════════════════════════════════════════
// Detour(FnType) — HadesMem-style type-safe generic hook
// ═══════════════════════════════════════════════════════════════════════
/// Comptime-generic typed detour — the recommended primary API for hooking.
///
/// Declare the target function's type once; get type-checked `attach`,
/// `callOriginal`, and `detach` with no manual pointer casts. Wraps
/// `GenericHook` for auto-sizing and relocation.
///
/// Supports all x86-32 calling conventions: `stdcall`, `cdecl`, `thiscall`,
/// and `fastcall` (requires patched Zig 0.16 with `inreg` fix for correct
/// x86 fastcall codegen). Uses `.never_tail` internally to prevent tail-call
/// optimization that would corrupt the stack with callee-cleanup conventions.
///
/// ```zig
/// const fc: std.builtin.CallingConvention = .{ .x86_fastcall = .{} };
/// const CheckFile = fn (u32, u32, u32) callconv(fc) u32;
/// var hook: Detour(CheckFile) = .{};
/// _ = hook.attach(0x654DD0, &myDetour);
/// // in detour: hook.callOriginal(.{ filename, flags, output });
/// hook.detach();
/// ```
pub fn Detour(comptime TargetFnType: type) type {
const FnInfo = @typeInfo(TargetFnType).@"fn";
const TargetFnPtr = *const TargetFnType;
const ReturnType = FnInfo.return_type orelse void;
return struct {
/// The underlying `GenericHook` managing the trampoline and patch.
inner: GenericHook = .{},
const Self = @This();
/// Hook the function at `target` to redirect to `detour`.
/// The `detour` function pointer must match `TargetFnType` exactly.
pub fn attach(self: *Self, target: usize, detour: TargetFnPtr) GenericHook.Error {
const err = self.inner.prepare(target);
if (err != .ok) return err;
self.inner.activate(@intFromPtr(detour));
return .ok;
}
/// Call the original (pre-hook) function through the trampoline.
/// Pass args as a tuple: `.{ arg1, arg2, arg3 }`.
///
/// Uses `.never_tail` to prevent tail-call optimization, which would
/// corrupt the stack with callee-cleanup conventions (stdcall/fastcall/
/// thiscall) — a tail call reuses the caller's stack frame, but the
/// callee pops args itself, leaving ESP pointing at garbage on return.
pub fn callOriginal(self: *const Self, args: anytype) ReturnType {
const orig: *const TargetFnType = @ptrFromInt(self.inner.trampoline);
return @call(.never_tail, orig, args);
}
/// Unhook: restore original bytes, free trampoline.
pub fn detach(self: *Self) void {
self.inner.remove();
}
/// Get the trampoline as a raw function pointer of the target type.
///
/// Unlike `callOriginal`, calling through this pointer does NOT force
/// `.never_tail` — the compiler may tail-call optimize, which corrupts
/// the stack for callee-cleanup conventions. Prefer `callOriginal`
/// unless you specifically need the function pointer (e.g. to pass as
/// a callback).
pub fn original(self: *const Self) *const TargetFnType {
return @ptrFromInt(self.inner.trampoline);
}
};
}
/// Resolve absolute target of a Jcc rel32 (0F 8x xx xx xx xx) — 6 byte insn.
fn jcc32Target(addr: usize) usize {
const disp: u32 = @bitCast(@as(*align(1) const i32, @ptrFromInt(addr + 2)).*);
return (addr + 6) +% disp;
}