Files
WeirdUtils/src/outline/tracker.zig
T
MarcelineVQ 2aaa089c76 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
2026-03-06 13:27:35 -08:00

347 lines
13 KiB
Zig

//! Per-frame model tracking for the outline system.
//!
//! 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.
//!
//! 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
//! comparison against the object manager's validated set.
const std = @import("std");
const hook = @import("zhook");
const wow = @import("wow.zig");
const o = @import("offsets.zig");
const types = @import("types.zig");
// =============================================================================
// Tracking limits
// =============================================================================
const MAX_TRACKED_OBJS = 128;
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.
const TrackedObj = struct {
obj_ptr: u32,
category: types.ModelCategory,
raid_mark: u8,
};
var tracked_objs: [MAX_TRACKED_OBJS]TrackedObj = undefined;
pub var tracked_obj_count: usize = 0;
// =============================================================================
// Per-frame outline model set (populated by ManageRenderListNode hook)
// =============================================================================
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)
// =============================================================================
// 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
// outline entries since game objects don't get outlines.
const MAX_GAME_OBJ_MODELS = 256;
var game_obj_ptrs: [MAX_TRACKED_OBJS]u32 = undefined;
var game_obj_ptr_count: usize = 0;
var game_obj_models: [MAX_GAME_OBJ_MODELS]u32 = undefined;
var game_obj_model_count: usize = 0;
// =============================================================================
// Global enable flag
// =============================================================================
pub var enabled: bool = true;
// =============================================================================
// Public query API
// =============================================================================
/// Look up a model pointer in the per-frame outline set.
pub fn findOutlineEntry(model_ptr: u32) ?*const types.OutlineEntry {
for (frame_outlines[0..frame_outline_count]) |*entry| {
if (entry.model_ptr == model_ptr) return entry;
}
return null;
}
/// Check if any outline targets are tracked this frame.
pub fn hasTargets() bool {
return tracked_obj_count > 0;
}
/// Get the outline colour for a model, or null if not tracked.
pub fn getModelColor(model_ptr: u32) ?u32 {
const entry = findOutlineEntry(model_ptr) orelse return null;
return switch (entry.category) {
.target => types.COLOR_TARGET,
.raid_marked => if (entry.raid_mark > 0 and entry.raid_mark <= 8)
types.RAID_MARK_COLORS[entry.raid_mark]
else
types.COLOR_DEAD_PLAYER,
.dead_player => types.COLOR_DEAD_PLAYER,
.none => null,
};
}
/// Get the outline category for a model.
pub fn getModelCategory(model_ptr: u32) types.ModelCategory {
const entry = findOutlineEntry(model_ptr) orelse return .none;
return entry.category;
}
/// Check if a model belongs to a game object (for render ordering).
pub fn isGameObjectModel(model_ptr: u32) bool {
for (game_obj_models[0..game_obj_model_count]) |m| {
if (m == model_ptr) return true;
}
return false;
}
/// Get screen-space outline thickness in pixels for a category.
pub fn getOutlinePixels(cat: types.ModelCategory) f32 {
return switch (cat) {
.target => types.OUTLINE_PIXELS_TARGET,
.raid_marked => types.OUTLINE_PIXELS_RAID_MARK,
.dead_player => types.OUTLINE_PIXELS_DEAD_PLAYER,
.none => 0,
};
}
// =============================================================================
// Per-frame model registration (called from ManageRenderListNode hook)
// =============================================================================
/// 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
/// the back-pointer values as pointers.
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.
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).
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| {
if (owner_callback == tracked.obj_ptr or owner_direct == tracked.obj_ptr) {
addOutlineEntry(model_ptr, tracked.category, tracked.raid_mark);
break;
}
}
}
}
// Match against game object pointers (for render ordering).
if (game_obj_ptr_count > 0 and game_obj_model_count < MAX_GAME_OBJ_MODELS) {
for (game_obj_ptrs[0..game_obj_ptr_count]) |go_ptr| {
if (owner_callback == go_ptr or owner_direct == go_ptr) {
// Deduplicate
var found = false;
for (game_obj_models[0..game_obj_model_count]) |m| {
if (m == model_ptr) {
found = true;
break;
}
}
if (!found) {
game_obj_models[game_obj_model_count] = model_ptr;
game_obj_model_count += 1;
}
break;
}
}
}
}
fn addOutlineEntry(model_ptr: u32, cat: types.ModelCategory, mark: u8) void {
if (frame_outline_count >= MAX_OUTLINE_MODELS) return;
frame_outlines[frame_outline_count] = .{
.model_ptr = model_ptr,
.category = cat,
.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 => {},
}
}
// =============================================================================
// Per-frame scan (called from EndScene)
// =============================================================================
/// Scan all visible objects and build the tracked object pointer sets.
/// Stores object pointers directly so classifyModel can match model
/// back-pointers without any pointer dereferencing.
pub fn scanObjects() void {
// Clear per-frame sets
frame_outline_count = 0;
tracked_obj_count = 0;
game_obj_ptr_count = 0;
game_obj_model_count = 0;
resetDiag();
if (!wow.isInGame()) return;
const local_player = wow.getLocalPlayer();
if (local_player == 0) return;
// Local player renders before outline targets (occludes outlines) unless
// the local player IS an outline target (partition logic checks outline first).
if (game_obj_ptr_count < MAX_TRACKED_OBJS) {
game_obj_ptrs[game_obj_ptr_count] = local_player;
game_obj_ptr_count += 1;
}
// Cache raid target GUIDs
wow.cacheRaidTargets();
// 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);
if (target_obj != 0) {
addTrackedObj(target_obj, .target, 0);
}
}
// Iterate all visible objects
var obj = wow.objectFirst();
while (obj != 0) : (obj = wow.objectNext(obj)) {
const obj_type = wow.getObjectType(obj);
const guid = wow.getObjectGUID(obj);
if (guid == 0) continue;
switch (obj_type) {
.player => {
if (wow.isUnitDead(obj) and wow.isUnitFriendly(obj, local_player)) {
addTrackedObj(obj, .dead_player, 0);
}
const mark = wow.getRaidMarkForGUID(guid);
if (mark != 0) addTrackedObj(obj, .raid_marked, mark);
},
.unit => {
const mark = wow.getRaidMarkForGUID(guid);
if (mark != 0) addTrackedObj(obj, .raid_marked, mark);
},
.corpse => {
if (!wow.isSkeletonCorpse(obj)) {
addTrackedObj(obj, .dead_player, 0);
}
},
.game_object => {
if (game_obj_ptr_count < MAX_TRACKED_OBJS) {
game_obj_ptrs[game_obj_ptr_count] = obj;
game_obj_ptr_count += 1;
}
},
else => {},
}
}
}
fn addTrackedObj(obj_ptr: u32, cat: types.ModelCategory, mark: u8) void {
if (obj_ptr == 0 or tracked_obj_count >= MAX_TRACKED_OBJS) return;
// Deduplicate; higher priority (lower enum value) wins
for (tracked_objs[0..tracked_obj_count]) |*existing| {
if (existing.obj_ptr == obj_ptr) {
if (@intFromEnum(cat) < @intFromEnum(existing.category)) {
existing.category = cat;
existing.raid_mark = mark;
}
return;
}
}
tracked_objs[tracked_obj_count] = .{
.obj_ptr = obj_ptr,
.category = cat,
.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 => {},
}
}
// =============================================================================
// 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;
}