Add module control API, bigcursor with fractional scaling, shared mutex

Module control API:
- Three cdecl exports: WeirdUtils_IsModuleActive, WeirdUtils_DisableModule, WeirdUtils_DisableAll
- C header include/weirdutils_api.h for runtime DLL discovery
- All modules gain pub module_name, isActive(), shared mutex via src/mutex.zig
- build.zig refactored to module_list array of ModuleDesc

Bigcursor:
- D3D9 vtable hooks on SetCursorProperties/ShowCursor
- Scale2x/Scale3x pixel-art upscalers in pure Zig (replaces 12K-line hqx C)
- Bilinear resampler for fractional scales (1.0-4.0, default 1.2)
- Win32 cursor via CreateIconIndirect bypasses D3D9 32x32 limit
- FNV-1a hash cache for ~16 cursor bitmaps
- Lua API: SetCursorScale(n) / GetCursorScale()
- CVar cursorScale for Config.wtf persistence (tenths)

Other:
- Add dpslog module stub
- Docs and README updates
This commit is contained in:
MarcelineVQ
2026-03-06 13:27:35 -08:00
parent 0783f3b0dd
commit 2aaa089c76
44 changed files with 1904 additions and 1022 deletions
+17 -17
View File
@@ -8,9 +8,9 @@ that runs entirely in EndScene, composited on top of the final backbuffer.
The outline system has three main phases per frame:
1. **Object scan** (EndScene start) — identify which game objects should be outlined
2. **DIP hook** (during game rendering) — cache draw calls and write stencil marks
3. **JFA pipeline** (EndScene, after game rendering) — produce and composite outlines
1. **Object scan** (EndScene start) - identify which game objects should be outlined
2. **DIP hook** (during game rendering) - cache draw calls and write stencil marks
3. **JFA pipeline** (EndScene, after game rendering) - produce and composite outlines
## Files
@@ -44,17 +44,17 @@ state to determine outline visibility.
`CM2SceneRenderDraw` receives a flat array of M2 batch indices. The hook partitions
them into 3 groups before calling the original function:
- **Group 1 — depth-priority models**: game object M2s and the local player's M2s.
- **Group 1 - depth-priority models**: game object M2s and the local player's M2s.
These render first so their depth is in the buffer when stencil marks are written.
The local player occludes outlines (they're the camera reference point).
If the local player IS an outline target, their models go in group 2 instead
(the outline check takes priority in the partition logic).
- **Group 2 — outline targets**: models belonging to tracked entities (current target,
- **Group 2 - outline targets**: models belonging to tracked entities (current target,
raid-marked units, dead friendly players). The DIP hook intercepts these draws to
cache parameters and write stencil=1 where they pass the depth test.
- **Group 3 — everything else**: other players, their gear, NPCs, creatures.
- **Group 3 - everything else**: other players, their gear, NPCs, creatures.
These render last. Their depth is NOT in the buffer when stencil marks are written,
so outlines show through them. The outline composites on top in EndScene regardless.
@@ -73,14 +73,14 @@ them into 3 groups before calling the original function:
The DIP hook writes stencil marks during outline target rendering (group 2):
- `STENCILFUNC = ALWAYS`, `STENCILPASS = REPLACE`, `STENCILREF = 1`
- `STENCILZFAIL = KEEP` — pixels behind depth-tested geometry keep stencil=0
- `STENCILZFAIL = KEEP` - pixels behind depth-tested geometry keep stencil=0
- After each outline DIP, `STENCILWRITEMASK` is set to 0 to protect marks from
subsequent draws (group 3 models could otherwise overwrite them)
- Exception: dead players skip stencil entirely (`STENCILENABLE = 0`) so their
outlines are visible through walls for corpse finding
EndScene Phase 1 uses `STENCILFUNC = EQUAL`, `STENCILREF = 1` to gate the
silhouette replay — only pixels marked as visible get silhouette color.
silhouette replay - only pixels marked as visible get silhouette color.
Stencil is cleared to 0 after Phase 1 to avoid affecting the next frame.
@@ -89,16 +89,16 @@ Stencil is cleared to 0 after Phase 1 to avoid affecting the next frame.
After Phase 1 produces the silhouette RT (A8R8G8B8), the JFA pipeline generates
outlines via distance field:
1. **JFA Init** — seed the distance field from the silhouette. Pixels with
1. **JFA Init** - seed the distance field from the silhouette. Pixels with
silhouette content (alpha >= 0.002) output their own UV as a seed.
Empty pixels output sentinel (-1, -1) which is outside UV space [0,1]
so it never wins distance comparisons.
2. **JFA Propagation** — 4 passes at step sizes [8, 4, 2, 1], ping-ponging
2. **JFA Propagation** - 4 passes at step sizes [8, 4, 2, 1], ping-ponging
between two G16R16F render targets. Each pass does a 9-tap sample
(self + 8 neighbors at step distance) and keeps the nearest seed UV.
3. **JFA Decode + Composite** — compute pixel-space distance from each pixel
3. **JFA Decode + Composite** - compute pixel-space distance from each pixel
to its nearest seed. If distance < outline width AND the pixel is outside
the silhouette interior, output the outline color with alpha blending.
@@ -121,7 +121,7 @@ Width is encoded as `alpha = pixels / 4.0` in the silhouette, decoded as
| `rt_jfa_a_tex` | G16R16F | JFA ping buffer (seed UV coordinates) |
| `rt_jfa_b_tex` | G16R16F | JFA pong buffer |
## DIP Hook — Draw Caching
## DIP Hook - Draw Caching
The DIP hook does NOT draw silhouettes inline (that corrupts WoW's GxDevice
internal render state). Instead it:
@@ -147,7 +147,7 @@ Each frame, `scanObjects()` iterates the WoW object manager and collects:
When `CM2Model_ManageRenderListNode` fires for each model being added to the
render list, `classifyModel()` reads the model's owner back-pointers
(`model+0x28` direct, `model+0x3C0` callback) and matches them against the
collected object pointers. No pointer dereferencing of unknown memory — just
collected object pointers. No pointer dereferencing of unknown memory - just
value comparison against the validated set from the object manager.
## Outline Categories
@@ -162,7 +162,7 @@ value comparison against the validated set from the object manager.
1. `api.init()` installs model hooks immediately (ManageRenderListNode,
DrawBatchProjected, CM2SceneRenderDraw)
2. D3D9 hooks are **deferred** until the first model hook fires — creating a
2. D3D9 hooks are **deferred** until the first model hook fires - creating a
dummy D3D9 device during engine init corrupts the proxy's state
3. `api.initD3D9Deferred()` patches the D3D9 vtable (EndScene, DIP, Reset)
4. Reset hook forces D24S8 depth/stencil format (8 stencil bits required)
@@ -189,7 +189,7 @@ OutlineCommand("off") -- disable outlines
- **Local player outline (planned)**: a Gaussian blur outline mode for the local
player to improve visibility in combat when surrounded by mobs. Separate from
the JFA pipeline — will use blur difference (blur silhouette, subtract original,
the JFA pipeline - will use blur difference (blur silhouette, subtract original,
threshold) for a softer glow effect and only apply to other players.
- **Death tracking needs improvement**: currently uses `UNIT_FLAG_DEAD` which is
@@ -197,10 +197,10 @@ OutlineCommand("off") -- disable outlines
- Dead players should be outlined (works now)
- Released bodies (corpse objects) should also be outlined (partially works via `.corpse`
type, but needs verification that released-but-not-skeleton corpses are caught)
- Feign Death must NOT trigger the dead outline — feign death sets the dead flag
- Feign Death must NOT trigger the dead outline - feign death sets the dead flag
but the player is alive. Need to check for the feign death aura/buff or use a
more specific death condition
- The local player should never get a death outline on themselves — the purpose
- The local player should never get a death outline on themselves - the purpose
of the death outline is to help the player find and resurrect others, not to
highlight their own corpse
- Marker outlines should not persist on units after they die
+13 -31
View File
@@ -11,16 +11,17 @@ const model_hook = @import("model_hook.zig");
const d3d9_hook = @import("d3d9_hook.zig");
const WINAPI = std.builtin.CallingConvention.winapi;
extern "kernel32" fn CreateMutexA(lpMutexAttributes: ?*anyopaque, bInitialOwner: i32, lpName: [*:0]const u8) callconv(WINAPI) ?*anyopaque;
extern "kernel32" fn ReleaseMutex(hMutex: *anyopaque) callconv(WINAPI) i32;
extern "kernel32" fn CloseHandle(hObject: *anyopaque) callconv(WINAPI) i32;
extern "kernel32" fn GetLastError() callconv(WINAPI) u32;
extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32;
const ERROR_ALREADY_EXISTS: u32 = 183;
const mod_mutex = @import("../mutex.zig");
pub const module_name: [*:0]const u8 = "outline";
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
pub fn isActive() bool {
return g_is_hook_owner;
}
/// Install model hooks immediately. D3D9 hooks are deferred until the first
/// model hook fires (i.e. the game is actively rendering), because creating a
/// dummy D3D9 device during engine init corrupts the d3d9 proxy's state and
@@ -28,29 +29,17 @@ var g_is_hook_owner: bool = false;
pub fn init() bool {
con.print("[outline] Module loaded\n");
// Multi-DLL safety: only one instance per process should hook
var mutex_name_buf: [64]u8 = undefined;
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_OutlineHook_{d}", .{GetCurrentProcessId()}) catch return false;
mutex_name_buf[mutex_name.len] = 0;
g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0]));
if (g_mutex == null) return false;
if (GetLastError() == ERROR_ALREADY_EXISTS) {
_ = CloseHandle(g_mutex.?);
g_mutex = null;
g_is_hook_owner = false;
con.print("[outline] Another DLL owns hooks (mutex taken), skipping\n");
return true;
}
g_is_hook_owner = true;
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) return true;
if (!model_hook.installHooks()) return false;
return true;
}
/// Called from the first model hook callback, once rendering is active.
/// Safe to create the dummy D3D9 device now — the game's real device is
/// Safe to create the dummy D3D9 device now - the game's real device is
/// fully initialised and the proxy's state is stable.
pub fn initD3D9Deferred() void {
_ = d3d9_hook.installHooks();
@@ -61,14 +50,7 @@ pub fn cleanup() void {
if (g_is_hook_owner) {
d3d9_hook.removeHooks();
model_hook.removeHooks();
}
if (g_is_hook_owner) {
if (g_mutex) |m| {
_ = ReleaseMutex(m);
_ = CloseHandle(m);
g_mutex = null;
}
mod_mutex.release(&g_mutex);
}
g_is_hook_owner = false;
}
+143 -114
View File
@@ -126,7 +126,7 @@ const CachedDraw = struct {
num_verts: u32 = 0,
start_idx: u32 = 0,
prim_count: u32 = 0,
// GPU state (AddRef'd COM objects — released after replay)
// GPU state (AddRef'd COM objects - released after replay)
vb: ?*anyopaque = null,
vb_offset: u32 = 0,
vb_stride: u32 = 0,
@@ -136,7 +136,7 @@ const CachedDraw = struct {
// Per-model outline info
color: u32 = 0,
category: types.ModelCategory = .none,
// VS constants (bone matrices, world/view/proj) — copied by value
// VS constants (bone matrices, world/view/proj) - copied by value
vs_consts: [MAX_VS_CONST_REGS][4]f32 = undefined,
};
@@ -197,8 +197,7 @@ fn deviceSetPtrOrNull(dev: *anyopaque, idx: usize, ptr: ?*anyopaque) void {
:
: [self] "r" (@intFromPtr(dev)),
[func] "r" (func_addr),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
}
@@ -251,8 +250,7 @@ fn deviceSetTexture(dev: *anyopaque, stage: u32, tex: ?*anyopaque) void {
: [self] "r" (@intFromPtr(dev)),
[stage] "r" (stage),
[func] "r" (func_addr),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
}
@@ -302,8 +300,7 @@ fn deviceSetStreamSource(dev: *anyopaque, stream: u32, vb: ?*anyopaque, offset:
: [self] "r" (@intFromPtr(dev)),
[a] "r" (&args),
[func] "r" (func_addr),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
}
@@ -337,8 +334,7 @@ fn deviceSetIndices(dev: *anyopaque, ib: ?*anyopaque) void {
:
: [self] "r" (@intFromPtr(dev)),
[func] "r" (func_addr),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
}
@@ -397,7 +393,7 @@ fn argbToFloat4(argb: u32) [4]f32 {
// Terrain depth snapshot
// =============================================================================
// (Terrain DS snapshot removed — DXVK does not support StretchRect for
// (Terrain DS snapshot removed - DXVK does not support StretchRect for
// depth-stencil surfaces. Terrain occlusion is achieved via stencil marks
// written during the DIP hook, when the game's DS already has terrain depth.)
@@ -420,38 +416,55 @@ fn ensureResources(device: *anyopaque) void {
resource_height = vp.Height;
// Silhouette RT (A8R8G8B8)
if (deviceCreateTexture(device, vp.Width, vp.Height, 1,
types.D3DUSAGE_RENDERTARGET, types.D3DFMT_A8R8G8B8, types.D3DPOOL_DEFAULT,
&rt_silhouette_tex) < 0) { releaseResources(); return; }
if (deviceCreateTexture(device, vp.Width, vp.Height, 1, types.D3DUSAGE_RENDERTARGET, types.D3DFMT_A8R8G8B8, types.D3DPOOL_DEFAULT, &rt_silhouette_tex) < 0) {
releaseResources();
return;
}
rt_silhouette_surf = textureGetSurfaceLevel(rt_silhouette_tex.?);
if (rt_silhouette_surf == null) { releaseResources(); return; }
if (rt_silhouette_surf == null) {
releaseResources();
return;
}
// JFA A RT (G16R16F)
if (deviceCreateTexture(device, vp.Width, vp.Height, 1,
types.D3DUSAGE_RENDERTARGET, types.D3DFMT_G16R16F, types.D3DPOOL_DEFAULT,
&rt_jfa_a_tex) < 0) { releaseResources(); return; }
if (deviceCreateTexture(device, vp.Width, vp.Height, 1, types.D3DUSAGE_RENDERTARGET, types.D3DFMT_G16R16F, types.D3DPOOL_DEFAULT, &rt_jfa_a_tex) < 0) {
releaseResources();
return;
}
rt_jfa_a_surf = textureGetSurfaceLevel(rt_jfa_a_tex.?);
if (rt_jfa_a_surf == null) { releaseResources(); return; }
if (rt_jfa_a_surf == null) {
releaseResources();
return;
}
// JFA B RT (G16R16F)
if (deviceCreateTexture(device, vp.Width, vp.Height, 1,
types.D3DUSAGE_RENDERTARGET, types.D3DFMT_G16R16F, types.D3DPOOL_DEFAULT,
&rt_jfa_b_tex) < 0) { releaseResources(); return; }
if (deviceCreateTexture(device, vp.Width, vp.Height, 1, types.D3DUSAGE_RENDERTARGET, types.D3DFMT_G16R16F, types.D3DPOOL_DEFAULT, &rt_jfa_b_tex) < 0) {
releaseResources();
return;
}
rt_jfa_b_surf = textureGetSurfaceLevel(rt_jfa_b_tex.?);
if (rt_jfa_b_surf == null) { releaseResources(); return; }
if (rt_jfa_b_surf == null) {
releaseResources();
return;
}
}
fn releaseResources() void {
inline for (.{
&rt_silhouette_surf, &rt_jfa_a_surf, &rt_jfa_b_surf,
}) |surf_ptr| {
if (surf_ptr.*) |s| { comRelease(s); surf_ptr.* = null; }
if (surf_ptr.*) |s| {
comRelease(s);
surf_ptr.* = null;
}
}
inline for (.{
&rt_silhouette_tex, &rt_jfa_a_tex, &rt_jfa_b_tex,
}) |tex_ptr| {
if (tex_ptr.*) |t| { comRelease(t); tex_ptr.* = null; }
if (tex_ptr.*) |t| {
comRelease(t);
tex_ptr.* = null;
}
}
resource_width = 0;
resource_height = 0;
@@ -461,7 +474,7 @@ fn releaseResources() void {
// Shader source strings
// =============================================================================
/// Flat colour pixel shader — outputs PS constant c0.
/// Flat colour pixel shader - outputs PS constant c0.
const ps_flat_src = "ps_3_0\nmov oC0, c0\n";
/// JFA init: sample silhouette, output own UV as seed or sentinel (-1,-1).
@@ -491,7 +504,7 @@ const jfa_prop_src =
"def c9, 1.0, 1.0, 0.0, 0.0\n" ++
"dcl_2d s0\n" ++
"dcl_texcoord0 v0\n" ++
// Self sample — initialize best seed and distance
// Self sample - initialize best seed and distance
"texld r0, v0, s0\n" ++
"sub r2.xy, v0.xy, r0.xy\n" ++
"dp2add r9.x, r2, r2, c1.x\n" ++ // best dist²
@@ -672,7 +685,10 @@ fn assemblePS(device: *anyopaque, assemble: D3DXAssembleShaderFn, src: [*]const
fn releaseShaders() void {
inline for (.{ &outline_ps, &jfa_init_ps, &jfa_prop_ps, &jfa_decode_ps, &debug_sil_ps }) |ps| {
if (ps.*) |p| { comRelease(p); ps.* = null; }
if (ps.*) |p| {
comRelease(p);
ps.* = null;
}
}
shaders_attempted = false;
}
@@ -731,7 +747,7 @@ fn hkEndScene(device: *anyopaque) callconv(sc) i32 {
}
// =============================================================================
// Reset hook — force D24S8 depth/stencil format, release resources
// Reset hook - force D24S8 depth/stencil format, release resources
// =============================================================================
fn hkReset(device: *anyopaque, pp: *types.D3DPRESENT_PARAMETERS) callconv(sc) i32 {
@@ -754,7 +770,7 @@ fn hkReset(device: *anyopaque, pp: *types.D3DPRESENT_PARAMETERS) callconv(sc) i3
}
// =============================================================================
// DrawIndexedPrimitive hook — cache outline draws for EndScene replay
// DrawIndexedPrimitive hook - cache outline draws for EndScene replay
// =============================================================================
// When an outline target is being drawn, we cache the draw parameters and
// current GPU state (VB, IB, vertex decl, VS, VS constants) so they can be
@@ -808,7 +824,7 @@ fn hkDIP(
// Capture current GPU state (AddRef COM objects to keep them alive)
deviceGetStreamSource(device, 0, &draw.vb, &draw.vb_offset, &draw.vb_stride);
// GetStreamSource AddRef's the VB — we keep the ref until replay
// GetStreamSource AddRef's the VB - we keep the ref until replay
draw.ib = deviceGetIndices(device);
// GetIndices AddRef's the IB
draw.vertex_decl = deviceGetPtr(device, types.VT.GetVertexDeclaration);
@@ -832,7 +848,7 @@ fn hkDIP(
const s_enable = deviceGetRS(device, types.D3DRS.STENCILENABLE);
const s_func = deviceGetRS(device, types.D3DRS.STENCILFUNC);
const s_ref = deviceGetRS(device, types.D3DRS.STENCILREF);
// (STENCILWRITEMASK not saved — intentionally set to 0 on restore)
// (STENCILWRITEMASK not saved - intentionally set to 0 on restore)
const s_pass = deviceGetRS(device, types.D3DRS.STENCILPASS);
const s_fail = deviceGetRS(device, types.D3DRS.STENCILFAIL);
const s_zfail = deviceGetRS(device, types.D3DRS.STENCILZFAIL);
@@ -856,7 +872,7 @@ fn hkDIP(
deviceSetRS(device, types.D3DRS.STENCILPASS, s_pass);
deviceSetRS(device, types.D3DRS.STENCILFAIL, s_fail);
deviceSetRS(device, types.D3DRS.STENCILZFAIL, s_zfail);
// Write mask 0 instead of restoring original — prevents any
// Write mask 0 instead of restoring original - prevents any
// subsequent DIP from overwriting our stencil=1 marks.
// Restored properly in EndScene before the JFA pipeline.
deviceSetRS(device, types.D3DRS.STENCILWRITEMASK, 0);
@@ -871,10 +887,22 @@ fn hkDIP(
fn clearCachedDraws() void {
for (0..cached_draw_count) |i| {
var draw = &cached_draws[i];
if (draw.vb) |obj| { comRelease(obj); draw.vb = null; }
if (draw.ib) |obj| { comRelease(obj); draw.ib = null; }
if (draw.vertex_decl) |obj| { comRelease(obj); draw.vertex_decl = null; }
if (draw.vertex_shader) |obj| { comRelease(obj); draw.vertex_shader = null; }
if (draw.vb) |obj| {
comRelease(obj);
draw.vb = null;
}
if (draw.ib) |obj| {
comRelease(obj);
draw.ib = null;
}
if (draw.vertex_decl) |obj| {
comRelease(obj);
draw.vertex_decl = null;
}
if (draw.vertex_shader) |obj| {
comRelease(obj);
draw.vertex_shader = null;
}
}
cached_draw_count = 0;
}
@@ -898,7 +926,7 @@ fn runJfaPipeline(device: *anyopaque) void {
// Save ALL state that replay + JFA will modify (manual, no state blocks)
// =====================================================================
// COM objects (Get* AddRefs — must Release after restore)
// COM objects (Get* AddRefs - must Release after restore)
const saved_rt0 = deviceGetRenderTarget(device, 0);
const saved_ps = deviceGetPtr(device, types.VT.GetPixelShader);
const saved_vs = deviceGetPtr(device, types.VT.GetVertexShader);
@@ -969,7 +997,7 @@ fn runJfaPipeline(device: *anyopaque) void {
deviceSetRenderTarget(device, 0, rt_silhouette_surf.?);
clearRenderTarget(device, 0x00000000);
// Keep game's DS bound — it has stencil marks from DIP hook where
// Keep game's DS bound - it has stencil marks from DIP hook where
// outline targets passed the terrain depth test (stencil=1 = visible).
// Don't write depth or stencil during replay.
deviceSetRS(device, types.D3DRS.ZWRITEENABLE, 0);
@@ -1006,8 +1034,7 @@ fn runJfaPipeline(device: *anyopaque) void {
deviceSetRS(device, types.D3DRS.STENCILPASS, types.D3DSTENCILOP_KEEP);
}
_ = origFn(device, draw.prim_type, draw.base_vtx, draw.min_vtx,
draw.num_verts, draw.start_idx, draw.prim_count);
_ = origFn(device, draw.prim_type, draw.base_vtx, draw.min_vtx, draw.num_verts, draw.start_idx, draw.prim_count);
}
clearCachedDraws();
@@ -1048,89 +1075,88 @@ fn runJfaPipeline(device: *anyopaque) void {
const quad = buildFullscreenQuad(vp.Width, vp.Height);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), @sizeOf(QuadVertex));
}
// Skip JFA — jump straight to state restore
// Skip JFA - jump straight to state restore
} else {
// =====================================================================
// Phase 2: JFA pipeline (silhouette → outline composite)
// =====================================================================
// =====================================================================
// Phase 2: JFA pipeline (silhouette → outline composite)
// =====================================================================
deviceSetPtrOrNull(device, types.VT.SetDepthStencilSurface, null);
deviceSetPtrOrNull(device, types.VT.SetVertexShader, null);
deviceSetFVF(device, types.D3DFVF_XYZRHW | types.D3DFVF_TEX1);
deviceSetRS(device, types.D3DRS.ZENABLE, types.D3DZB_FALSE);
deviceSetRS(device, types.D3DRS.ZWRITEENABLE, 0);
deviceSetRS(device, types.D3DRS.ALPHABLENDENABLE, 0);
deviceSetRS(device, types.D3DRS.CULLMODE, types.D3DCULL_NONE);
deviceSetRS(device, types.D3DRS.ALPHATESTENABLE, 0);
deviceSetRS(device, types.D3DRS.COLORWRITEENABLE, 0x0F);
deviceSetPtrOrNull(device, types.VT.SetDepthStencilSurface, null);
deviceSetPtrOrNull(device, types.VT.SetVertexShader, null);
deviceSetFVF(device, types.D3DFVF_XYZRHW | types.D3DFVF_TEX1);
deviceSetRS(device, types.D3DRS.ZENABLE, types.D3DZB_FALSE);
deviceSetRS(device, types.D3DRS.ZWRITEENABLE, 0);
deviceSetRS(device, types.D3DRS.ALPHABLENDENABLE, 0);
deviceSetRS(device, types.D3DRS.CULLMODE, types.D3DCULL_NONE);
deviceSetRS(device, types.D3DRS.ALPHATESTENABLE, 0);
deviceSetRS(device, types.D3DRS.COLORWRITEENABLE, 0x0F);
deviceSetSamplerState(device, 0, types.D3DSAMP.ADDRESSU, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 0, types.D3DSAMP.ADDRESSV, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 0, types.D3DSAMP.MAGFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 0, types.D3DSAMP.MINFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 0, types.D3DSAMP.MIPFILTER, types.D3DTEXF_NONE);
deviceSetSamplerState(device, 1, types.D3DSAMP.ADDRESSU, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 1, types.D3DSAMP.ADDRESSV, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 1, types.D3DSAMP.MAGFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 1, types.D3DSAMP.MINFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 1, types.D3DSAMP.MIPFILTER, types.D3DTEXF_NONE);
deviceSetSamplerState(device, 0, types.D3DSAMP.ADDRESSU, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 0, types.D3DSAMP.ADDRESSV, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 0, types.D3DSAMP.MAGFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 0, types.D3DSAMP.MINFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 0, types.D3DSAMP.MIPFILTER, types.D3DTEXF_NONE);
deviceSetSamplerState(device, 1, types.D3DSAMP.ADDRESSU, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 1, types.D3DSAMP.ADDRESSV, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 1, types.D3DSAMP.MAGFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 1, types.D3DSAMP.MINFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 1, types.D3DSAMP.MIPFILTER, types.D3DTEXF_NONE);
const quad = buildFullscreenQuad(vp.Width, vp.Height);
const qstride: u32 = @sizeOf(QuadVertex);
const fw = @as(f32, @floatFromInt(@max(vp.Width, 1)));
const fh = @as(f32, @floatFromInt(@max(vp.Height, 1)));
const quad = buildFullscreenQuad(vp.Width, vp.Height);
const qstride: u32 = @sizeOf(QuadVertex);
const fw = @as(f32, @floatFromInt(@max(vp.Width, 1)));
const fh = @as(f32, @floatFromInt(@max(vp.Height, 1)));
// Pass 1: JFA Init (silhouette → JFA_A)
deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?);
deviceSetTexture(device, 0, rt_silhouette_tex);
deviceSetPtr(device, types.VT.SetPixelShader, jfa_init_ps.?);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// Pass 1: JFA Init (silhouette → JFA_A)
deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?);
deviceSetTexture(device, 0, rt_silhouette_tex);
deviceSetPtr(device, types.VT.SetPixelShader, jfa_init_ps.?);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// JFA Propagation: steps [8, 4, 2, 1] ping-ponging between A and B.
deviceSetPtr(device, types.VT.SetPixelShader, jfa_prop_ps.?);
var c0: [4]f32 = undefined;
// JFA Propagation: steps [8, 4, 2, 1] ping-ponging between A and B.
deviceSetPtr(device, types.VT.SetPixelShader, jfa_prop_ps.?);
var c0: [4]f32 = undefined;
// step=8 (JFA_A → JFA_B)
deviceSetRenderTarget(device, 0, rt_jfa_b_surf.?);
deviceSetTexture(device, 0, rt_jfa_a_tex);
c0 = .{ 8.0 / fw, 8.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=8 (JFA_A → JFA_B)
deviceSetRenderTarget(device, 0, rt_jfa_b_surf.?);
deviceSetTexture(device, 0, rt_jfa_a_tex);
c0 = .{ 8.0 / fw, 8.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=4 (JFA_B → JFA_A)
deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?);
deviceSetTexture(device, 0, rt_jfa_b_tex);
c0 = .{ 4.0 / fw, 4.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=4 (JFA_B → JFA_A)
deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?);
deviceSetTexture(device, 0, rt_jfa_b_tex);
c0 = .{ 4.0 / fw, 4.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=2 (JFA_A → JFA_B)
deviceSetRenderTarget(device, 0, rt_jfa_b_surf.?);
deviceSetTexture(device, 0, rt_jfa_a_tex);
c0 = .{ 2.0 / fw, 2.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=2 (JFA_A → JFA_B)
deviceSetRenderTarget(device, 0, rt_jfa_b_surf.?);
deviceSetTexture(device, 0, rt_jfa_a_tex);
c0 = .{ 2.0 / fw, 2.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=1 (JFA_B → JFA_A)
deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?);
deviceSetTexture(device, 0, rt_jfa_b_tex);
c0 = .{ 1.0 / fw, 1.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// Pass 4: Decode + Composite (JFA_A + silhouette → backbuffer)
if (saved_rt0) |rt| deviceSetRenderTarget(device, 0, rt);
deviceSetTexture(device, 0, rt_jfa_a_tex);
deviceSetTexture(device, 1, rt_silhouette_tex);
c0 = [4]f32{ fw, fh, 4.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceSetPtr(device, types.VT.SetPixelShader, jfa_decode_ps.?);
deviceSetRS(device, types.D3DRS.ALPHABLENDENABLE, 1);
deviceSetRS(device, types.D3DRS.SRCBLEND, types.D3DBLEND_SRCALPHA);
deviceSetRS(device, types.D3DRS.DESTBLEND, types.D3DBLEND_INVSRCALPHA);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=1 (JFA_B → JFA_A)
deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?);
deviceSetTexture(device, 0, rt_jfa_b_tex);
c0 = .{ 1.0 / fw, 1.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// Pass 4: Decode + Composite (JFA_A + silhouette → backbuffer)
if (saved_rt0) |rt| deviceSetRenderTarget(device, 0, rt);
deviceSetTexture(device, 0, rt_jfa_a_tex);
deviceSetTexture(device, 1, rt_silhouette_tex);
c0 = [4]f32{ fw, fh, 4.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceSetPtr(device, types.VT.SetPixelShader, jfa_decode_ps.?);
deviceSetRS(device, types.D3DRS.ALPHABLENDENABLE, 1);
deviceSetRS(device, types.D3DRS.SRCBLEND, types.D3DBLEND_SRCALPHA);
deviceSetRS(device, types.D3DRS.DESTBLEND, types.D3DBLEND_INVSRCALPHA);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
} // end else (normal JFA path)
// =====================================================================
@@ -1174,7 +1200,10 @@ fn runJfaPipeline(device: *anyopaque) void {
deviceSetVSConstF(device, 0, &saved_vs_consts, MAX_VS_CONST_REGS);
// COM objects (restore binding then release our ref)
if (saved_rt0) |rt| { deviceSetRenderTarget(device, 0, rt); comRelease(rt); } // RT0
if (saved_rt0) |rt| {
deviceSetRenderTarget(device, 0, rt);
comRelease(rt);
} // RT0
deviceSetPtrOrNull(device, types.VT.SetDepthStencilSurface, saved_ds); // DS
if (saved_ds) |ds| comRelease(ds);
deviceSetPtrOrNull(device, types.VT.SetPixelShader, saved_ps); // PS
+10 -10
View File
@@ -1,12 +1,12 @@
//! WoW model rendering pipeline hooks.
//!
//! Hooks three WoW functions to integrate outline rendering:
//! - CM2SceneRenderDraw — reorders batches so outline targets render last.
//! - CM2Model_ManageRenderListNode — classifies models on render-list add.
//! - CM2Scene_DrawBatchProjected — flags the DIP hook for outline rendering.
//! - CM2SceneRenderDraw - reorders batches so outline targets render last.
//! - CM2Model_ManageRenderListNode - classifies models on render-list add.
//! - CM2Scene_DrawBatchProjected - flags the DIP hook for outline rendering.
//!
//! Calling conventions:
//! - RenderDraw & ManageRender use callconv(.x86_thiscall) — direct native detours.
//! - RenderDraw & ManageRender use callconv(.x86_thiscall) - direct native detours.
//! - DrawBatchProj uses a callconv(.naked) entry point because Zig 0.15 has a
//! codegen bug with callconv(.x86_fastcall) that generates wrong ret instructions
//! for functions with ≤2 register params. The naked wrapper bridges to a cdecl
@@ -64,12 +64,12 @@ var reordered_indices: [MAX_REORDER]i32 = undefined;
// CM2SceneRenderDraw hook
// =============================================================================
// __thiscall(this, viewMatrix, batchData, batchIndices, batchCount)
// Native thiscall detour — no thunk needed.
// Native thiscall detour - no thunk needed.
//
// Reorders batch indices into 3 groups:
// 1. Game objects/doodads — render first, write depth so outlines respect them
// 2. Outline targets — render second, DIP hook writes stencil against depth
// 3. Other players, gear, NPCs — render last, draw over targets normally
// 1. Game objects/doodads - render first, write depth so outlines respect them
// 2. Outline targets - render second, DIP hook writes stencil against depth
// 3. Other players, gear, NPCs - render last, draw over targets normally
//
// This gives outlines that are occluded by world/WMO/game objects but show
// through other players and gear (since those aren't in depth when stencil
@@ -145,7 +145,7 @@ fn renderDrawDetour(this: u32, view_matrix: u32, batch_data: u32, batch_indices:
// CM2Model_ManageRenderListNode hook
// =============================================================================
// __thiscall(model_ECX, addToList_stack)
// Native thiscall detour — no thunk needed.
// Native thiscall detour - no thunk needed.
fn manageRenderDetour(model: u32, add_to_list: u32) callconv(tc) void {
// Classify the model when it's being ADDED to the render list
@@ -180,7 +180,7 @@ fn drawBatchProjDetour(ctx: u32) callconv(tc) void {
const entry = if (model_ptr != 0) tracker.findOutlineEntry(model_ptr) else null;
if (entry != null) {
// This batch is an outline target — signal the DIP hook
// This batch is an outline target - signal the DIP hook
rendering_outline = true;
current_model = model_ptr;
+4 -4
View File
@@ -93,7 +93,7 @@ pub const IS_IN_WORLD: usize = 0xB4B424;
// =============================================================================
/// __stdcall(guidLo_stack, guidHi_stack) → object pointer (EAX). Returns 0 on miss.
/// Callee cleans 8 bytes (RET 8). NOT __fastcall — params on stack, not registers.
/// Callee cleans 8 bytes (RET 8). NOT __fastcall - params on stack, not registers.
pub const FN_GET_OBJECT_BY_GUID: usize = 0x464870;
/// __fastcall(unitIdStr_ECX) → GUID in EAX:EDX. Accepts "player", "target", etc.
@@ -106,14 +106,14 @@ pub const FN_UNIT_REACTION: usize = 0x6061E0;
// Hooked function addresses (model rendering pipeline)
// =============================================================================
/// CM2SceneRenderDraw — main batch rendering entry point.
/// CM2SceneRenderDraw - main batch rendering entry point.
/// __thiscall(this, viewMatrix, batchData, batchIndices, batchCount)
pub const FN_CM2SCENE_RENDER_DRAW: usize = 0x0070b360;
/// CM2Model_ManageRenderListNode — called for every model added/removed from render list.
/// CM2Model_ManageRenderListNode - called for every model added/removed from render list.
/// __thiscall(model_ECX, addToList_stack)
pub const FN_CM2MODEL_MANAGE_RENDER_LIST: usize = 0x00710b90;
/// CM2Scene_DrawModelBatchProjected — called per batch (type 0) during rendering.
/// CM2Scene_DrawModelBatchProjected - called per batch (type 0) during rendering.
/// __fastcall(renderContext_ECX)
pub const FN_DRAW_BATCH_PROJ: usize = 0x0070cb30;
+10 -7
View File
@@ -2,12 +2,12 @@
//!
//! Maintains fixed-size arrays of tracked object pointers (target, raid marks,
//! dead players) and the per-frame set of outline model entries.
//! All state is single-threaded (main WoW thread) — no synchronisation needed.
//! All state is single-threaded (main WoW thread) - no synchronisation needed.
//!
//! Model classification uses forward mapping: scanObjects stores object pointers
//! from the object manager, then classifyModel (ManageRenderListNode) reads the
//! model's back-pointers (model+0x28, model+0x3C0) and compares them against
//! known object pointers. No dereferencing of unknown memory — just value
//! known object pointers. No dereferencing of unknown memory - just value
//! comparison against the object manager's validated set.
const std = @import("std");
@@ -27,7 +27,7 @@ const MAX_OUTLINE_MODELS = 256;
// Tracked object set (populated by scanObjects from the object manager)
// =============================================================================
// Stores obj_ptr + category for entities we want to outline.
// classifyModel matches model back-pointers against these — no pointer chasing.
// classifyModel matches model back-pointers against these - no pointer chasing.
const TrackedObj = struct {
obj_ptr: u32,
@@ -46,7 +46,7 @@ var frame_outlines: [MAX_OUTLINE_MODELS]types.OutlineEntry = undefined;
var frame_outline_count: usize = 0;
// =============================================================================
// Per-frame game object tracking (for render ordering — game objects first)
// Per-frame game object tracking (for render ordering - game objects first)
// =============================================================================
// Game object M2 models need to render before outline targets so their depth
// is in the buffer when stencil marks are written. Tracked separately from
@@ -127,7 +127,7 @@ pub fn getOutlinePixels(cat: types.ModelCategory) f32 {
/// Classify a model by comparing its back-pointers against known object pointers.
/// Safe: only reads from the model struct (which WoW just handed us via the
/// ManageRenderListNode __thiscall), then compares values — never dereferences
/// ManageRenderListNode __thiscall), then compares values - never dereferences
/// the back-pointer values as pointers.
pub fn classifyModel(model_ptr: u32) void {
if (model_ptr == 0 or !enabled) return;
@@ -155,7 +155,10 @@ pub fn classifyModel(model_ptr: u32) void {
// Deduplicate
var found = false;
for (game_obj_models[0..game_obj_model_count]) |m| {
if (m == model_ptr) { found = true; break; }
if (m == model_ptr) {
found = true;
break;
}
}
if (!found) {
game_obj_models[game_obj_model_count] = model_ptr;
@@ -214,7 +217,7 @@ pub fn scanObjects() void {
// Cache raid target GUIDs
wow.cacheRaidTargets();
// Resolve target to object pointer (highest priority — added first)
// Resolve target to object pointer (highest priority - added first)
const target_guid = wow.getTargetGUID();
if (target_guid != 0) {
const target_obj = wow.getObjectByGUID(target_guid);
+9 -9
View File
@@ -41,7 +41,7 @@ pub const OutlineEntry = struct {
};
// =============================================================================
// Outline colours — D3DCOLOR ARGB format (0xAARRGGBB)
// Outline colours - D3DCOLOR ARGB format (0xAARRGGBB)
// =============================================================================
/// Dead player / corpse outline (cyan).
@@ -53,14 +53,14 @@ pub const COLOR_TARGET: u32 = 0xFFFFC800;
/// Raid mark colours, indexed 0-8. Index 0 = fallback cyan.
pub const RAID_MARK_COLORS = [9]u32{
0xFF00FFFF, // 0: fallback
0xFFFFFF00, // 1: Star — Yellow
0xFFFF8000, // 2: Circle — Orange
0xFFCC44FF, // 3: Diamond — Purple
0xFF00FF00, // 4: Triangle — Green
0xFFC0C0FF, // 5: Moon — Silver/Pale Blue
0xFF4040FF, // 6: Square — Blue
0xFFFF2828, // 7: Cross — Soft Red
0xFFFFF5DC, // 8: Skull — Bone White
0xFFFFFF00, // 1: Star - Yellow
0xFFFF8000, // 2: Circle - Orange
0xFFCC44FF, // 3: Diamond - Purple
0xFF00FF00, // 4: Triangle - Green
0xFFC0C0FF, // 5: Moon - Silver/Pale Blue
0xFF4040FF, // 6: Square - Blue
0xFFFF2828, // 7: Cross - Soft Red
0xFFFFF5DC, // 8: Skull - Bone White
};
// =============================================================================
+6 -9
View File
@@ -20,7 +20,7 @@ extern "kernel32" fn IsBadReadPtr(
ucb: usize,
) callconv(WINAPI) i32;
/// Quick sanity check — reject null, low-address, and kernel-space pointers.
/// Quick sanity check - reject null, low-address, and kernel-space pointers.
pub fn isValidPtr(addr: u32) bool {
return addr >= 0x10000 and addr < 0x7F000000;
}
@@ -118,7 +118,7 @@ pub fn resolveModelOwner(model: u32) u32 {
// Need to read up to model+0x3C0+4
if (!isReadablePtr(model, o.MODEL_OWNER_CALLBACK + 4)) return 0;
// Try callback owner first — more reliable for units
// Try callback owner first - more reliable for units
const candidate_cb = hook.readMem(u32, model + o.MODEL_OWNER_CALLBACK);
if (candidate_cb != 0 and isReadablePtr(candidate_cb, 0x40)) {
const guid_lo = hook.readMem(u32, candidate_cb + o.OBJECT_GUID_OFFSET);
@@ -149,14 +149,13 @@ pub fn unitGUID(unit_id: [*:0]const u8) u64 {
[_] "={edx}" (hi),
: [_] "{ecx}" (@intFromPtr(unit_id)),
[func] "r" (@as(u32, o.FN_UNIT_GUID)),
: .{ .memory = true, .cc = true }
);
: .{ .memory = true, .cc = true });
return (@as(u64, hi) << 32) | lo;
}
/// Resolve a GUID → object pointer via the object manager hash table.
/// Ghidra-verified: __stdcall(guidLow, guidHigh) with RET 8.
/// NOT __fastcall — params are read from stack, not registers.
/// NOT __fastcall - params are read from stack, not registers.
pub fn getObjectByGUID(guid: u64) u32 {
if (guid == 0) return 0;
const lo: u32 = @truncate(guid);
@@ -169,8 +168,7 @@ pub fn getObjectByGUID(guid: u64) u32 {
: [lo] "r" (lo),
[hi] "r" (hi),
[func] "r" (@as(u32, o.FN_GET_OBJECT_BY_GUID)),
: .{ .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .ecx = true, .edx = true, .memory = true, .cc = true });
}
/// Get the local player's object pointer.
@@ -197,8 +195,7 @@ pub fn isUnitFriendly(unit: u32, local_player: u32) bool {
: [_] "{ecx}" (local_player),
[unit] "r" (unit),
[func] "r" (@as(u32, o.FN_UNIT_REACTION)),
: .{ .edx = true, .memory = true, .cc = true }
);
: .{ .edx = true, .memory = true, .cc = true });
return reaction >= 4;
}