Fix outline rendering: cached DIP replay, disable depth for silhouettes

Switch from double-DIP (which corrupted WoW's GxDevice state) to cached
replay in EndScene. Cache draw params + GPU state (VB, IB, vertex decl,
VS, 256 VS constant registers) during the DIP hook, replay to silhouette
RT in EndScene before the JFA pipeline.

Remove D3D9 state blocks entirely — use comprehensive manual save/restore
of all modified state (render states, sampler states, shader constants,
COM objects with proper AddRef/Release).

Disable depth testing for all silhouette replay categories. By EndScene
the depth buffer has the full scene, so LESSEQUAL testing creates holes
in the silhouette wherever other models overlap — breaking the JFA
outline for targets and raid marks. Corpses already used ZENABLE=FALSE.

Add per-frame diagnostic counters (scan/classify counts by category)
logged via OutputDebugStringA for the first 20 active frames.

Clean up unused code: comAddRef, deviceCreateStateBlock, stateBlockApply.
This commit is contained in:
MarcelineVQ
2026-02-24 12:27:50 -08:00
parent 3ec7796cc1
commit 528bbf2470
4 changed files with 1069 additions and 527 deletions
+897 -409
View File
File diff suppressed because it is too large Load Diff
+37 -55
View File
@@ -1,7 +1,7 @@
//! WoW model rendering pipeline hooks.
//!
//! Hooks three WoW functions to integrate outline rendering:
//! - CM2SceneRenderDraw — reorders batches so outline targets render first.
//! - CM2SceneRenderDraw — captures terrain depth before M2 models draw.
//! - CM2Model_ManageRenderListNode — classifies models on render-list add.
//! - CM2Scene_DrawBatchProjected — flags the DIP hook for outline rendering.
//!
@@ -18,6 +18,7 @@ const api = @import("api.zig");
const o = @import("offsets.zig");
const types = @import("types.zig");
const tracker = @import("tracker.zig");
const d3d9_hook = @import("d3d9_hook.zig");
const wow = @import("wow.zig");
// =============================================================================
@@ -48,15 +49,8 @@ pub var rendering_outline: bool = false;
/// Model pointer currently being rendered (for colour lookup in DIP).
pub var current_model: u32 = 0;
/// Set after outline targets render; tells DIP to apply stencil test on
/// subsequent unit batches so outlines aren't covered by nearby players.
pub var test_outline_stencil: bool = false;
/// True while the current DrawBatchProj batch is a unit (player/NPC).
pub var rendering_unit: bool = false;
// =============================================================================
// Batch reordering limits
// Batch reordering
// =============================================================================
const MAX_REORDER = 1024;
@@ -67,6 +61,10 @@ var reordered_indices: [MAX_REORDER]i32 = undefined;
// =============================================================================
// __thiscall(this, viewMatrix, batchData, batchIndices, batchCount)
// Native thiscall detour — no thunk needed.
//
// Reorders batch indices so outline targets draw first, when the game's
// depth buffer contains only terrain + WMO geometry. The DIP hook renders
// silhouettes using the game's own DS for depth testing (ZWRITEENABLE=FALSE).
fn renderDrawDetour(this: u32, view_matrix: u32, batch_data: u32, batch_indices: u32, batch_count: u32) callconv(THISCALL) void {
// One-time: install D3D9 hooks now that the game is actively rendering.
@@ -75,54 +73,51 @@ fn renderDrawDetour(this: u32, view_matrix: u32, batch_data: u32, batch_indices:
api.initD3D9Deferred();
}
// If outlines disabled or nothing tracked, fast-path to original
if (!tracker.enabled or !tracker.hasTargets() or batch_count == 0 or
batch_data == 0 or batch_indices == 0 or batch_count > MAX_REORDER)
{
// Skip reordering if nothing to outline or too many batches
if (!tracker.enabled or !tracker.hasTargets() or batch_count == 0 or batch_count > MAX_REORDER) {
callOrigRenderDraw(this, view_matrix, batch_data, batch_indices, batch_count);
return;
}
// Single pass: partition batches into outline-targets vs normal,
// keeping relative order within each group.
var outline_buf: [MAX_REORDER]i32 = undefined;
var normal_buf: [MAX_REORDER]i32 = undefined;
var o_count: usize = 0;
var n_count: usize = 0;
const indices: [*]i32 = @ptrFromInt(batch_indices);
const indices: [*]const i32 = @ptrFromInt(batch_indices);
// Pass 1: count outline targets
var outline_count: u32 = 0;
for (0..batch_count) |i| {
const idx = indices[i];
const batch_ptr = batch_data +% @as(u32, @bitCast(idx)) *% 0x40;
const model_ptr = hook.readMem(u32, batch_ptr + 4);
const idx_u: u32 = @bitCast(indices[i]);
const model_ptr = hook.readMem(u32, batch_data +% idx_u *% 0x40 +% 4);
if (model_ptr != 0 and tracker.findOutlineEntry(model_ptr) != null) {
outline_buf[o_count] = idx;
o_count += 1;
} else {
normal_buf[n_count] = idx;
n_count += 1;
outline_count += 1;
}
}
if (o_count == 0) {
// Nothing to reorder
if (outline_count == 0) {
callOrigRenderDraw(this, view_matrix, batch_data, batch_indices, batch_count);
return;
}
// Build reordered array: outline targets FIRST (populate stencil), then normals
var total: usize = 0;
for (outline_buf[0..o_count]) |v| {
reordered_indices[total] = v;
total += 1;
}
for (normal_buf[0..n_count]) |v| {
reordered_indices[total] = v;
total += 1;
// Pass 2: partition — outline targets first, then everything else.
var outline_pos: u32 = 0;
var normal_pos: u32 = outline_count;
for (0..batch_count) |i| {
const batch_idx = indices[i];
const idx_u: u32 = @bitCast(batch_idx);
const model_ptr = hook.readMem(u32, batch_data +% idx_u *% 0x40 +% 4);
if (model_ptr != 0 and tracker.findOutlineEntry(model_ptr) != null) {
reordered_indices[outline_pos] = batch_idx;
outline_pos += 1;
} else {
reordered_indices[normal_pos] = batch_idx;
normal_pos += 1;
}
}
callOrigRenderDraw(this, view_matrix, batch_data, @intFromPtr(&reordered_indices), @intCast(total));
// Write reordered indices back to game's array in-place
for (0..batch_count) |i| {
indices[i] = reordered_indices[i];
}
callOrigRenderDraw(this, view_matrix, batch_data, batch_indices, batch_count);
}
fn callOrigRenderDraw(this: u32, view_matrix: u32, batch_data: u32, batch_indices: u32, batch_count: u32) void {
@@ -216,22 +211,9 @@ fn drawBatchProjImpl(ctx: u32, _edx: u32) callconv(.c) void {
rendering_outline = false;
current_model = 0;
// After outline targets render, enable stencil test for subsequent units
test_outline_stencil = true;
} else {
// Normal rendering. Determine if this is a unit for stencil testing.
rendering_outline = false;
current_model = 0;
rendering_unit = false;
if (model_ptr != 0) {
rendering_unit = tracker.isUnitModel(model_ptr);
}
// Normal rendering — no special handling needed
callOrigDrawBatch(ctx);
rendering_unit = false;
}
}
+72 -55
View File
@@ -10,6 +10,7 @@
//! known object pointers. No dereferencing of unknown memory — just value
//! comparison against the object manager's validated set.
const std = @import("std");
const hook = @import("hook");
const wow = @import("wow.zig");
const o = @import("offsets.zig");
@@ -20,7 +21,6 @@ const types = @import("types.zig");
// =============================================================================
const MAX_TRACKED_OBJS = 128;
const MAX_UNIT_OBJS = 512;
const MAX_OUTLINE_MODELS = 256;
// =============================================================================
@@ -38,10 +38,6 @@ const TrackedObj = struct {
var tracked_objs: [MAX_TRACKED_OBJS]TrackedObj = undefined;
pub var tracked_obj_count: usize = 0;
// All unit/player object pointers for stencil occlusion detection.
var unit_obj_ptrs: [MAX_UNIT_OBJS]u32 = .{0} ** MAX_UNIT_OBJS;
var unit_obj_count: usize = 0;
// =============================================================================
// Per-frame outline model set (populated by ManageRenderListNode hook)
// =============================================================================
@@ -49,10 +45,6 @@ var unit_obj_count: usize = 0;
var frame_outlines: [MAX_OUTLINE_MODELS]types.OutlineEntry = undefined;
var frame_outline_count: usize = 0;
// Per-frame unit model cache (model pointers belonging to units).
var frame_unit_models: [MAX_UNIT_OBJS]u32 = .{0} ** MAX_UNIT_OBJS;
var frame_unit_model_count: usize = 0;
// =============================================================================
// Global enable flag
// =============================================================================
@@ -106,16 +98,6 @@ pub fn getOutlinePixels(cat: types.ModelCategory) f32 {
};
}
/// Check if a model was classified as a unit (player/NPC) this frame.
/// Used by DrawBatchProj to decide stencil testing without raw pointer chasing.
pub fn isUnitModel(model_ptr: u32) bool {
if (model_ptr == 0) return false;
for (frame_unit_models[0..frame_unit_model_count]) |m| {
if (m == model_ptr) return true;
}
return false;
}
// =============================================================================
// Per-frame model registration (called from ManageRenderListNode hook)
// =============================================================================
@@ -128,15 +110,10 @@ pub fn classifyModel(model_ptr: u32) void {
if (model_ptr == 0 or !enabled) return;
// Read the model's back-pointers to its owning game object.
// Safe reads: the model struct is guaranteed valid — WoW is calling
// ManageRenderListNode on it right now. We read u32 values and
// compare them; we never dereference these values as pointers.
const owner_direct = hook.readMem(u32, model_ptr + o.MODEL_OWNER_DIRECT);
const owner_callback = hook.readMem(u32, model_ptr + o.MODEL_OWNER_CALLBACK);
// Match against tracked outline objects (target, raid marks, dead players).
// Priority is implicit in insertion order: target first, then raid marks,
// then dead players — first match wins.
if (tracked_obj_count > 0 and frame_outline_count < MAX_OUTLINE_MODELS) {
if (findOutlineEntry(model_ptr) == null) {
for (tracked_objs[0..tracked_obj_count]) |tracked| {
@@ -147,25 +124,6 @@ pub fn classifyModel(model_ptr: u32) void {
}
}
}
// Match against all unit/player objects for stencil occlusion.
if (unit_obj_count > 0 and frame_unit_model_count < MAX_UNIT_OBJS) {
for (unit_obj_ptrs[0..unit_obj_count]) |uptr| {
if (owner_callback == uptr or owner_direct == uptr) {
addUnitModel(model_ptr);
break;
}
}
}
}
fn addUnitModel(model_ptr: u32) void {
if (frame_unit_model_count >= MAX_UNIT_OBJS) return;
for (frame_unit_models[0..frame_unit_model_count]) |m| {
if (m == model_ptr) return;
}
frame_unit_models[frame_unit_model_count] = model_ptr;
frame_unit_model_count += 1;
}
fn addOutlineEntry(model_ptr: u32, cat: types.ModelCategory, mark: u8) void {
@@ -176,6 +134,14 @@ fn addOutlineEntry(model_ptr: u32, cat: types.ModelCategory, mark: u8) void {
.raid_mark = mark,
};
frame_outline_count += 1;
// Diagnostic: count classified models by category
switch (cat) {
.target => diag.classify_target += 1,
.raid_marked => diag.classify_raid_mark += 1,
.dead_player => diag.classify_dead_player += 1,
.none => {},
}
}
// =============================================================================
@@ -188,9 +154,8 @@ fn addOutlineEntry(model_ptr: u32, cat: types.ModelCategory, mark: u8) void {
pub fn scanObjects() void {
// Clear per-frame sets
frame_outline_count = 0;
frame_unit_model_count = 0;
tracked_obj_count = 0;
unit_obj_count = 0;
resetDiag();
if (!wow.isInGame()) return;
const local_player = wow.getLocalPlayer();
@@ -217,8 +182,6 @@ pub fn scanObjects() void {
switch (obj_type) {
.player => {
addUnitObjPtr(obj);
if (wow.isUnitDead(obj) and wow.isUnitFriendly(obj, local_player)) {
addTrackedObj(obj, .dead_player, 0);
}
@@ -226,14 +189,10 @@ pub fn scanObjects() void {
if (mark != 0) addTrackedObj(obj, .raid_marked, mark);
},
.unit => {
addUnitObjPtr(obj);
const mark = wow.getRaidMarkForGUID(guid);
if (mark != 0) addTrackedObj(obj, .raid_marked, mark);
},
.corpse => {
// Track the corpse object itself — its model's back-pointer
// (model+0x28) should point back to this corpse object.
if (!wow.isSkeletonCorpse(obj)) {
addTrackedObj(obj, .dead_player, 0);
}
@@ -261,10 +220,68 @@ fn addTrackedObj(obj_ptr: u32, cat: types.ModelCategory, mark: u8) void {
.raid_mark = mark,
};
tracked_obj_count += 1;
// Diagnostic: count tracked objects by category
switch (cat) {
.target => diag.scan_targets += 1,
.raid_marked => diag.scan_raid_marks += 1,
.dead_player => diag.scan_dead_players += 1,
.none => {},
}
}
fn addUnitObjPtr(obj_ptr: u32) void {
if (obj_ptr == 0 or unit_obj_count >= MAX_UNIT_OBJS) return;
unit_obj_ptrs[unit_obj_count] = obj_ptr;
unit_obj_count += 1;
// =============================================================================
// Diagnostics (per-frame counters, logged from EndScene)
// =============================================================================
pub const Diag = struct {
// scanObjects counts (how many objects were added to tracked_objs)
scan_targets: u16 = 0,
scan_raid_marks: u16 = 0,
scan_dead_players: u16 = 0,
// classifyModel counts (how many models matched tracked objects)
classify_target: u16 = 0,
classify_raid_mark: u16 = 0,
classify_dead_player: u16 = 0,
// Number of frames logged so far
log_count: u16 = 0,
};
pub var diag: Diag = .{};
const WINAPI = std.builtin.CallingConvention.winapi;
extern "kernel32" fn OutputDebugStringA(lpOutputString: [*:0]const u8) callconv(WINAPI) void;
/// Log diagnostic counters via OutputDebugStringA. Called from EndScene.
/// Only logs when outline activity is detected, limited to first 20 events.
pub fn logDiagnostics(cached_draw_count: u32) void {
const has_activity = diag.scan_targets > 0 or diag.scan_raid_marks > 0 or
diag.scan_dead_players > 0 or diag.classify_target > 0 or
diag.classify_raid_mark > 0 or diag.classify_dead_player > 0 or
cached_draw_count > 0;
if (!has_activity or diag.log_count >= 20) return;
diag.log_count += 1;
var buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "[Outline] scan t={d} r={d} d={d} | classify t={d} r={d} d={d} | cached={d}\x00", .{
diag.scan_targets,
diag.scan_raid_marks,
diag.scan_dead_players,
diag.classify_target,
diag.classify_raid_mark,
diag.classify_dead_player,
cached_draw_count,
}) catch return;
OutputDebugStringA(@ptrCast(msg.ptr));
}
/// Reset per-frame diagnostic counters. Called at start of scanObjects.
fn resetDiag() void {
diag.scan_targets = 0;
diag.scan_raid_marks = 0;
diag.scan_dead_players = 0;
diag.classify_target = 0;
diag.classify_raid_mark = 0;
diag.classify_dead_player = 0;
}
+63 -8
View File
@@ -71,13 +71,6 @@ pub const OUTLINE_PIXELS_TARGET: f32 = 2.25;
pub const OUTLINE_PIXELS_RAID_MARK: f32 = 1.5;
pub const OUTLINE_PIXELS_DEAD_PLAYER: f32 = 2.5;
// =============================================================================
// Stencil bit definitions
// =============================================================================
pub const STENCIL_BIT_BODY: u32 = 0x01;
pub const STENCIL_BIT_OUTLINE: u32 = 0x02;
// =============================================================================
// D3D9 Render State IDs (D3DRENDERSTATETYPE)
// =============================================================================
@@ -86,6 +79,7 @@ pub const D3DRS = struct {
pub const ZENABLE: u32 = 7;
pub const FILLMODE: u32 = 8;
pub const ZWRITEENABLE: u32 = 14;
pub const ALPHATESTENABLE: u32 = 15;
pub const SRCBLEND: u32 = 19;
pub const DESTBLEND: u32 = 20;
pub const CULLMODE: u32 = 22;
@@ -115,14 +109,23 @@ pub const D3DCMP_LESSEQUAL: u32 = 4;
pub const D3DSTENCILOP_KEEP: u32 = 1;
pub const D3DSTENCILOP_REPLACE: u32 = 3;
pub const D3DCULL_NONE: u32 = 1;
pub const D3DCULL_CW: u32 = 2;
pub const D3DCULL_CCW: u32 = 3;
pub const D3DZB_FALSE: u32 = 0;
pub const D3DZB_TRUE: u32 = 1;
pub const D3DCLEAR_TARGET: u32 = 1;
pub const D3DCLEAR_STENCIL: u32 = 4;
// =============================================================================
// D3D9 blend modes
// =============================================================================
pub const D3DBLEND_SRCALPHA: u32 = 5;
pub const D3DBLEND_INVSRCALPHA: u32 = 6;
// =============================================================================
// D3D9 texture stage state IDs
// =============================================================================
@@ -137,6 +140,34 @@ pub const D3DTSS = struct {
pub const D3DTOP_SELECTARG1: u32 = 2;
pub const D3DTA_TFACTOR: u32 = 3;
// =============================================================================
// D3D9 sampler state IDs (D3DSAMPLERSTATETYPE)
// =============================================================================
pub const D3DSAMP = struct {
pub const ADDRESSU: u32 = 1;
pub const ADDRESSV: u32 = 2;
pub const MAGFILTER: u32 = 5;
pub const MINFILTER: u32 = 6;
pub const MIPFILTER: u32 = 7;
};
pub const D3DTADDRESS_CLAMP: u32 = 3;
// =============================================================================
// D3D9 texture filter types
// =============================================================================
pub const D3DTEXF_NONE: u32 = 0;
pub const D3DTEXF_POINT: u32 = 1;
// =============================================================================
// D3D9 surface/texture formats
// =============================================================================
pub const D3DFMT_A8R8G8B8: u32 = 21;
pub const D3DFMT_G16R16F: u32 = 112;
// =============================================================================
// D3D9 depth/stencil formats
// =============================================================================
@@ -146,30 +177,54 @@ pub const D3DFMT_D24X4S4: u32 = 79;
pub const D3DFMT_D15S1: u32 = 73;
pub const D3DFMT_D24FS8: u32 = 83;
// =============================================================================
// D3D9 pool / usage / FVF / primitive types
// =============================================================================
pub const D3DPOOL_DEFAULT: u32 = 0;
pub const D3DUSAGE_RENDERTARGET: u32 = 1;
pub const D3DFVF_XYZRHW: u32 = 0x004;
pub const D3DFVF_TEX1: u32 = 0x100;
pub const D3DPT_TRIANGLESTRIP: u32 = 5;
pub const D3DSBT_ALL: u32 = 1;
// =============================================================================
// IDirect3DDevice9 vtable indices
// =============================================================================
pub const VT = struct {
pub const Release: usize = 2;
pub const Reset: usize = 16;
pub const GetSwapChain: usize = 14;
pub const Reset: usize = 16;
pub const CreateTexture: usize = 23;
pub const CreateDepthStencilSurface: usize = 29;
pub const StretchRect: usize = 34;
pub const SetRenderTarget: usize = 37;
pub const GetRenderTarget: usize = 38;
pub const SetDepthStencilSurface: usize = 39;
pub const GetDepthStencilSurface: usize = 40;
pub const BeginScene: usize = 41;
pub const EndScene: usize = 42;
pub const Clear: usize = 43;
pub const SetViewport: usize = 47;
pub const GetViewport: usize = 48;
pub const CreateStateBlock: usize = 59;
pub const SetRenderState: usize = 57;
pub const GetRenderState: usize = 58;
pub const GetTexture: usize = 64;
pub const SetTexture: usize = 65;
pub const SetTextureStageState: usize = 67;
pub const GetSamplerState: usize = 68;
pub const SetSamplerState: usize = 69;
pub const DrawIndexedPrimitive: usize = 82;
pub const DrawPrimitiveUP: usize = 83;
pub const CreateVertexDeclaration: usize = 86;
pub const SetVertexDeclaration: usize = 87;
pub const GetVertexDeclaration: usize = 88;
pub const SetFVF: usize = 89;
pub const CreateVertexShader: usize = 91;
pub const SetVertexShader: usize = 92;
pub const GetVertexShader: usize = 93;