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.
This commit is contained in:
MarcelineVQ
2026-03-25 01:30:16 -07:00
parent e78ebfd74d
commit 28514c029c
3 changed files with 114 additions and 93 deletions
+108 -88
View File
@@ -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);
}
+5 -4
View File
@@ -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;
+1 -1
View File
@@ -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;
}