From 28514c029c34b811f97ba9aee655ac2a9dfeb07b Mon Sep 17 00:00:00 2001 From: MarcelineVQ Date: Wed, 25 Mar 2026 01:30:16 -0700 Subject: [PATCH] clickthrough: cascade raycast with custom CanTargetEntity filter bits Replace the re-raycast approach with a priority cascade: loot > GO > NPC > normal. Each pass uses custom flag bits (0x01/02/04 in upper byte) that our CanTargetEntity hook reads to exclude non-matching objects at the raycast level. Terrain/WMO occlusion applies per pass. Hook CanTargetEntity (0x480610) for per-object filtering: strips custom bits before calling original, then checks lootable/interactable/NPC based on which pass is active. Objects that don't match the current pass return NULL (invisible to raycast, ray continues through them). Fix isLootable: UNIT_DYNAMIC_FLAGS was at wrong descriptor offset (0x96*4=0x258, should be 0x8F*4=0x23C per server UpdateFields index 143). Also add UNIT_DYNFLAG_TAPPED and UNIT_DYNFLAG_TAPPED_BY_PLAYER offsets. Fix isLootable base pointer: use getDescriptor (obj+0x08) not getUnitDescriptor (obj+0x110), consistent with getNpcFlags and all other descriptor reads in the project. --- src/clickthrough/clickthrough.zig | 196 ++++++++++++++++-------------- src/offsets.zig | 9 +- src/wow.zig | 2 +- 3 files changed, 114 insertions(+), 93 deletions(-) diff --git a/src/clickthrough/clickthrough.zig b/src/clickthrough/clickthrough.zig index d8c6668..0af3989 100644 --- a/src/clickthrough/clickthrough.zig +++ b/src/clickthrough/clickthrough.zig @@ -1,12 +1,18 @@ //! Click-through module. //! -//! Makes interactable objects clickable through players and units by hooking -//! WorldIntersectionTest (0x480DF0). When the raycast hits a player, we -//! re-raycast without players to find interactable NPCs or GOs behind them. -//! When it hits a unit, we re-raycast GO-only to find interactable GOs. +//! Priority raycast cascade: instead of one raycast that picks the nearest +//! object, we run up to 3 filtered raycasts in priority order. Each pass +//! uses custom flag bits in CanTargetEntity to exclude unwanted objects at +//! the raycast level (terrain/WMO occlusion applies per pass). +//! +//! Pass 1 (loot): only lootable corpses +//! Pass 2 (GO): only interactable game objects +//! Pass 3 (NPC): only units with NPC interaction flags +//! Fallthrough: normal unfiltered behavior //! //! Hooks: -//! WorldIntersectionTest (0x480DF0) -- sole raycast entry, called from HitTestPoint +//! CanTargetEntity (0x480610) - per-object filter, reads custom flag bits +//! WorldIntersectionTest (0x480DF0) - runs the cascade const std = @import("std"); const hook = @import("zhook"); @@ -18,10 +24,11 @@ const wow = @import("../wow.zig"); pub const module_name: [*:0]const u8 = "clickthrough"; // ============================================================================= -// WoW addresses (module-specific) +// Addresses // ============================================================================= const ADDR_WorldIntersectionTest: usize = 0x480DF0; +const ADDR_CanTargetEntity: usize = 0x480610; // ============================================================================= // HitTestResult layout @@ -31,41 +38,14 @@ const HIT_GUID_LO: usize = 0x00; const HIT_GUID_HI: usize = 0x04; const HIT_RESULT_SIZE: usize = 0x34; -// Object struct offsets -const OBJ_TYPE_MASK_OFFSET: usize = 0x08; // at *(*(obj+8)+8) +// ============================================================================= +// Custom raycast flag bits (upper bits unused by game) +// ============================================================================= -// Type masks -const TYPE_UNIT: u32 = 0x09; -const TYPE_PLAYER: u32 = 0x19; - -// Raycast flags -const FLAG_GO: u32 = 0x04; - -/// Check if the GUID refers to an interactable GO. -fn isInteractableGO(guid_lo: u32, guid_hi: u32) bool { - const obj = wow.getObjectByGUIDSplit(guid_lo, guid_hi); - if (obj == 0) return false; - if (wow.getObjectTypeRaw(obj) != @intFromEnum(wow.ObjectType.game_object)) return false; - return hook.call(fn (u32) callconv(hook.cc.fastcall) u8, offsets.FN_CALL_SPELL_CAST_HANDLER, .{obj}) != 0; -} - -/// Check if the GUID refers to an interactable unit: NPC with interaction flags, -/// or a lootable corpse (dead unit with UNIT_DYNFLAG_LOOTABLE). -fn isInteractableNPC(guid_lo: u32, guid_hi: u32) bool { - const obj = wow.getObjectByGUIDSplit(guid_lo, guid_hi); - if (obj == 0) return false; - if (wow.getObjectTypeRaw(obj) != @intFromEnum(wow.ObjectType.unit)) return false; - if (wow.getNpcFlags(obj) != 0) return true; - return wow.isLootable(obj); -} - -/// Check if the second raycast result is something we should click through to. -fn isClickthroughTarget(guid_lo: u32, guid_hi: u32, allow_npcs: bool) bool { - if (guid_lo == 0 and guid_hi == 0) return false; - if (isInteractableGO(guid_lo, guid_hi)) return true; - if (allow_npcs and isInteractableNPC(guid_lo, guid_hi)) return true; - return false; -} +const FLAG_LOOT_ONLY: u32 = 0x01000000; // pass 1: only lootable corpses +const FLAG_GO_ONLY: u32 = 0x02000000; // pass 2: only interactable GOs +const FLAG_NPC_ONLY: u32 = 0x04000000; // pass 3: only interactable NPCs +const FLAG_CUSTOM_MASK: u32 = FLAG_LOOT_ONLY | FLAG_GO_ONLY | FLAG_NPC_ONLY; // ============================================================================= // Hook state @@ -73,66 +53,105 @@ fn isClickthroughTarget(guid_lo: u32, guid_hi: u32, allow_npcs: bool) bool { var g_mutex: ?*anyopaque = null; var g_is_hook_owner: bool = false; +var log: logging.Logger = .{}; + +// CanTargetEntity: CanTargetEntity(void *obj, uint permissionFlags) -> undefined* +// Returns non-NULL to include object, NULL to exclude. +// __cdecl-ish but called with obj as first stack arg from CheckObjectTypePermissions. +// Assembly: PUSH permFlags; PUSH objPtr; CALL CanTargetEntity +// Actually looking at the call site it passes obj in register and flags on stack. +// Let me verify from the CheckObjectTypePermissions assembly. +// From decompile: puVar4 = CanTargetEntity(pvVar3, permissionFlags); +// pvVar3 is the resolved object pointer. permissionFlags is the raycast flags. +// The function signature from Ghidra: CanTargetEntity(void *param_1, uint param_2) +// Not thiscall/fastcall - it's a regular call with two stack args. + +const CanTargetFn = fn (u32, u32) callconv(.{ .x86_stdcall = .{} }) u32; +var cte_hook: hook.Detour(CanTargetFn) = .{}; -// WorldIntersectionTest: __thiscall(ECX=worldFrame, rayStart*, rayEnd*, flags, hitResult*) -> hitType -// RET 0x10 (4 stack args) const WorldIntersectFn = fn (u32, u32, u32, u32, u32) callconv(hook.cc.thiscall) u32; var wit_hook: hook.Detour(WorldIntersectFn) = .{}; -var log: logging.Logger = .{}; +// ============================================================================= +// Hook: CanTargetEntity (0x480610) +// Per-object filter called during raycast enumeration. +// When custom flag bits are set, exclude objects that don't match the pass. +// ============================================================================= + +fn canTargetDetour(obj: u32, perm_flags: u32) callconv(.{ .x86_stdcall = .{} }) u32 { + // Strip custom bits before passing to original + const clean_flags = perm_flags & ~FLAG_CUSTOM_MASK; + const original = cte_hook.callOriginal(.{ obj, clean_flags }); + + // If original says exclude, respect that + if (original == 0) return 0; + + // No custom filtering active - pass through + if ((perm_flags & FLAG_CUSTOM_MASK) == 0) return original; + + // Custom pass filtering + if ((perm_flags & FLAG_LOOT_ONLY) != 0) { + // Only lootable corpses pass + if (!wow.isLootable(obj)) return 0; + return original; + } + + if ((perm_flags & FLAG_GO_ONLY) != 0) { + // Only interactable GOs pass + const desc = wow.getDescriptor(obj); + if (!wow.isValidPtr(desc)) return 0; + const type_mask = hook.readMem(u32, desc + 0x08); + if (type_mask != 0x21) return 0; // not a GO + // Check interactability + if (hook.call(fn (u32) callconv(hook.cc.fastcall) u8, offsets.FN_CALL_SPELL_CAST_HANDLER, .{obj}) == 0) + return 0; + return original; + } + + if ((perm_flags & FLAG_NPC_ONLY) != 0) { + // Only units with NPC interaction flags pass + if (wow.getNpcFlags(obj) == 0) return 0; + return original; + } + + return original; +} // ============================================================================= // Hook: WorldIntersectionTest (0x480DF0) -// Called from HitTestPoint with the ray and flags. We call original, check -// the result, and if it's a unit/player, re-call with GO-only flags. +// Priority raycast cascade: loot > GO > NPC > normal // ============================================================================= fn worldIntersectDetour(world_frame: u32, ray_start: u32, ray_end: u32, flags: u32, hit_result: u32) callconv(hook.cc.thiscall) u32 { - // Call original with caller's flags - const hit_type = wit_hook.callOriginal(.{ world_frame, ray_start, ray_end, flags, hit_result }); + // Don't cascade if we're already in a custom pass (prevent recursion) + // or if in a battleground + if (!g_is_hook_owner or (flags & FLAG_CUSTOM_MASK) != 0 or wow.isInBattleground()) { + return wit_hook.callOriginal(.{ world_frame, ray_start, ray_end, flags, hit_result }); + } - // No click-through in battlegrounds - if (!g_is_hook_owner or hit_result == 0 or hit_type != 2 or wow.isInBattleground()) return hit_type; + // Pass 1: lootable corpses (units + dead + our custom filter) + { + const loot_flags = (flags | FLAG_LOOT_ONLY) & ~@as(u32, 0x10); // include units, exclude players + const hit_type = wit_hook.callOriginal(.{ world_frame, ray_start, ray_end, loot_flags, hit_result }); + if (hit_type == 2) return hit_type; + } - // hitType 2 = object hit. Check if it's a unit/player. - const buf_lo = hook.readMem(u32, hit_result + HIT_GUID_LO); - const buf_hi = hook.readMem(u32, hit_result + HIT_GUID_HI); + // Pass 2: interactable game objects + { + const go_flags = (flags | FLAG_GO_ONLY); + const hit_type = wit_hook.callOriginal(.{ world_frame, ray_start, ray_end, go_flags, hit_result }); + if (hit_type == 2) return hit_type; + } - if (buf_lo == 0 and buf_hi == 0) return hit_type; + // Pass 3: interactable NPCs + { + const npc_flags = (flags | FLAG_NPC_ONLY) & ~@as(u32, 0x10); // include units, exclude players + const hit_type = wit_hook.callOriginal(.{ world_frame, ray_start, ray_end, npc_flags, hit_result }); + if (hit_type == 2) return hit_type; + } - const obj = wow.getObjectByGUIDSplit(buf_lo, buf_hi); - if (obj == 0) return hit_type; - - const desc_ptr = wow.getDescriptor(obj); - if (!wow.isValidPtr(desc_ptr)) return hit_type; - - const type_mask = hook.readMem(u32, desc_ptr + OBJ_TYPE_MASK_OFFSET); - - // Determine re-raycast flags and whether NPCs are valid targets: - // Player hit → remove player flag, allow NPCs + GOs (including lootable corpses) - // Unit hit → GO-only flags, only allow GOs - const recast_flags: u32 = switch (type_mask) { - TYPE_PLAYER => flags & ~@as(u32, 0x10), // everything except players - TYPE_UNIT => FLAG_GO, // GOs only - else => return hit_type, - }; - const allow_npcs = (type_mask == TYPE_PLAYER); - - var recast_result = [_]u8{0} ** HIT_RESULT_SIZE; - const recast_hit_type = wit_hook.callOriginal(.{ world_frame, ray_start, ray_end, recast_flags, @intFromPtr(&recast_result) }); - - if (recast_hit_type < 2) return hit_type; - - const r_guid_lo = std.mem.readInt(u32, recast_result[HIT_GUID_LO..][0..4], .little); - const r_guid_hi = std.mem.readInt(u32, recast_result[HIT_GUID_HI..][0..4], .little); - - if (!isClickthroughTarget(r_guid_lo, r_guid_hi, allow_npcs)) return hit_type; - - // Replace the caller's hitResult with the recast result - const dst: [*]u8 = @ptrFromInt(hit_result); - @memcpy(dst[0..HIT_RESULT_SIZE], &recast_result); - - return recast_hit_type; + // Fallthrough: normal unfiltered raycast + return wit_hook.callOriginal(.{ world_frame, ray_start, ray_end, flags, hit_result }); } // ============================================================================= @@ -150,14 +169,15 @@ pub fn installHooks() void { if (!g_is_hook_owner) return; log = logging.Logger.open(module_name, .console); + _ = cte_hook.attach(ADDR_CanTargetEntity, &canTargetDetour); _ = wit_hook.attach(ADDR_WorldIntersectionTest, &worldIntersectDetour); - - log.fmt("WorldIntersectionTest hooked at 0x{x}\n", .{ADDR_WorldIntersectionTest}); + log.print("clickthrough: cascade raycast active\n"); } pub fn removeHooks() void { if (g_is_hook_owner) { wit_hook.detach(); + cte_hook.detach(); log.close(); mod_mutex.release(&g_mutex); } diff --git a/src/offsets.zig b/src/offsets.zig index a703563..9dad679 100644 --- a/src/offsets.zig +++ b/src/offsets.zig @@ -42,11 +42,12 @@ pub const DESC_ENTRY: usize = 0x03 * 4; /// UNIT_NPC_FLAGS = OBJECT_END(0x06) + 0x8D = 0x93 pub const DESC_NPC_FLAGS: usize = 0x93 * 4; -/// UNIT_DYNAMIC_FLAGS = OBJECT_END(0x06) + 0x90 = 0x96 -pub const DESC_UNIT_DYNAMIC_FLAGS: usize = 0x96 * 4; +/// UNIT_DYNAMIC_FLAGS: server UpdateFields index 143 (0x8F), byte offset 0x23C +pub const DESC_UNIT_DYNAMIC_FLAGS: usize = 0x8F * 4; -/// Bit 0: unit corpse is lootable by the local player. -pub const UNIT_DYNFLAG_LOOTABLE: u32 = 0x01; +pub const UNIT_DYNFLAG_LOOTABLE: u32 = 0x0001; +pub const UNIT_DYNFLAG_TAPPED: u32 = 0x0004; +pub const UNIT_DYNFLAG_TAPPED_BY_PLAYER: u32 = 0x0008; /// GAMEOBJECT_TYPE_ID (index 0x15) pub const DESC_GO_TYPE: usize = 0x15 * 4; diff --git a/src/wow.zig b/src/wow.zig index 8c6343b..ffdefea 100644 --- a/src/wow.zig +++ b/src/wow.zig @@ -124,7 +124,7 @@ pub fn getNpcFlags(obj: u32) u32 { pub fn isLootable(obj: u32) bool { const desc = getDescriptor(obj); - if (!isValidPtr(desc)) return 0 != 0; + if (!isValidPtr(desc)) return false; return (hook.readMem(u32, desc + o.DESC_UNIT_DYNAMIC_FLAGS) & o.UNIT_DYNFLAG_LOOTABLE) != 0; }