clickthrough: block unusable player-summoned portals and rituals

Filter ritual (type 18) and mage portal (type 22) GOs whose creator
is a player not in the local player's party or raid. Completely
unclickable, not just deprioritized.

Also:
- Add wow.isInGroup() shared group membership check (party + raid)
- Add party/raid addresses to shared offsets.zig
- Fix dpslog party member GUID address (was 0xBC7600, correct: 0xBC6F48)
  Verified from client's is_player_in_allowed_list @ 0x4e7f70
This commit is contained in:
MarcelineVQ
2026-03-28 11:42:38 -07:00
parent 5b85390206
commit 96687bfaaa
6 changed files with 417 additions and 7 deletions
+11 -3
View File
@@ -20,6 +20,7 @@ const logging = @import("../logging.zig");
const mod_mutex = @import("../mutex.zig");
const offsets = @import("../offsets.zig");
const wow = @import("../wow.zig");
const portal_filter = @import("portal_filter.zig");
pub const module_name: [*:0]const u8 = "clickthrough";
@@ -80,9 +81,6 @@ fn checkObjTypeDetour(ctx: u32, obj_data: u32, perm_flags: u32) callconv(hook.cc
// 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;
// Resolve the object pointer from obj_data via ClntObjMgrObjectPtr.
// __fastcall(ECX=typeMask, EDX=debugStr, stack: guid_lo, guid_hi, debugCode)
// RET 0xC. See nampower ClntObjMgrObjectPtrT typedef.
@@ -97,6 +95,16 @@ fn checkObjTypeDetour(ctx: u32, obj_data: u32, perm_flags: u32) callconv(hook.cc
if (!wow.isValidPtr(desc)) return original;
const type_mask = hook.readMem(u32, desc + 0x08);
// Always block unusable player-summoned portals/rituals (all passes)
if (type_mask == 0x21) {
const go_type = hook.readMem(u32, desc + offsets.DESC_GO_TYPE);
if ((go_type == 18 or go_type == 22) and portal_filter.shouldFilter(desc))
return 0;
}
// No custom filtering active - pass through
if ((perm_flags & FLAG_CUSTOM_MASK) == 0) return original;
if ((perm_flags & FLAG_LOOT_ONLY) != 0) {
if (type_mask != 0x09) return 0; // units only
if (!wow.isLootable(obj)) return 0;
+25
View File
@@ -0,0 +1,25 @@
//! portal_filter -- click filtering for summoned ritual and portal GOs.
//!
//! Filters ritual (type 18) and spellcaster/portal (type 22) game objects
//! whose creator is not in the player's party or raid. The client reports
//! these as "interactable" (same faction) but the server rejects the click
//! if you're not grouped with the summoner.
const hook = @import("zhook");
const wow = @import("../wow.zig");
const DESC_CREATED_BY_LO: usize = 0x06 * 4;
const DESC_CREATED_BY_HI: usize = 0x07 * 4;
/// Returns true if this summoned GO should be filtered (player creator not in group).
/// NPC-created GOs are never filtered.
pub fn shouldFilter(desc: u32) bool {
const creator_lo = hook.readMem(u32, desc + DESC_CREATED_BY_LO);
const creator_hi = hook.readMem(u32, desc + DESC_CREATED_BY_HI);
const creator_guid = @as(u64, creator_hi) << 32 | creator_lo;
if (creator_guid == 0) return false;
// High type 0x0000 = player GUID. Non-player creators are always allowed.
const high_type: u16 = @truncate(creator_guid >> 48);
if (high_type != 0x0000) return false;
return !wow.isInGroup(creator_guid);
}
+309 -4
View File
@@ -50,6 +50,118 @@ var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
var log: logging.Logger = .{};
// =============================================================================
// Cast/Channel state tracking — for UnitCastingInfo / UnitChannelInfo
// =============================================================================
/// OsGetAsyncTimeMs (0x42B790): __stdcall() -> u64 (ms). Same timebase as Lua GetTime().
fn getTimeMs() u64 {
return hook.call(fn () callconv(hook.cc.stdcall) u64, 0x42B790, .{});
}
const CastState = struct {
guid: u64 = 0,
spell_id: u32 = 0,
start_ms: u64 = 0,
end_ms: u64 = 0,
is_channel: bool = false,
};
const CAST_TABLE_SIZE = 32;
var cast_table: [CAST_TABLE_SIZE]CastState = [_]CastState{.{}} ** CAST_TABLE_SIZE;
var cast_id_counter: u32 = 0; // monotonic cast ID for UnitCastingInfo
fn setCastState(guid: u64, spell_id: u32, duration_ms: u32, is_channel: bool) void {
const now = getTimeMs();
// Find existing slot for this GUID, or oldest slot to evict
var best: usize = 0;
var oldest: u64 = ~@as(u64, 0);
for (&cast_table, 0..) |*entry, i| {
if (entry.guid == guid) {
best = i;
break;
}
if (entry.start_ms < oldest) {
oldest = entry.start_ms;
best = i;
}
}
cast_table[best] = .{
.guid = guid,
.spell_id = spell_id,
.start_ms = now,
.end_ms = now + duration_ms,
.is_channel = is_channel,
};
if (!is_channel) {
cast_id_counter +%= 1;
}
}
fn clearCastState(guid: u64) void {
for (&cast_table) |*entry| {
if (entry.guid == guid) {
entry.* = .{};
return;
}
}
}
fn getCastState(guid: u64, want_channel: bool) ?CastState {
const now = getTimeMs();
for (&cast_table) |*entry| {
if (entry.guid == guid and entry.is_channel == want_channel) {
// Expired?
if (now > entry.end_ms + 500) { // 500ms grace for latency
entry.* = .{};
return null;
}
return entry.*;
}
}
return null;
}
/// SpellDuration.dbc — for channel duration lookup
/// Record: [0]=ID(u32), [0x04]=baseDuration(i32 ms), [0x08]=durationPerLevel, [0x0C]=maxDuration
const SPELL_DURATION_RECORDS: u32 = 0xC0D828;
const SPELL_DURATION_MAX_ID: u32 = 0xC0D82C;
/// SpellRec field offsets for channel detection
const SPELL_ATTRIBUTES_EX: u32 = 0x1C; // AttributesEx (field 7)
const SPELL_DURATION_IDX: u32 = 0x78; // DurationIndex (field 30)
const SPELL_EFFECT_BASE: u32 = 0xF4; // Effect[0] (field 61)
/// SPELL_ATTR_EX_CHANNELED_1 | SPELL_ATTR_EX_CHANNELED_2
const CHANNELED_MASK: u32 = 0x44;
fn isChanneledSpell(spell_id: u32) bool {
const rec = getSpellRecord(spell_id) orelse return false;
return hook.readMem(u32, rec + SPELL_ATTRIBUTES_EX) & CHANNELED_MASK != 0;
}
fn getSpellDurationMs(spell_id: u32) u32 {
const rec = getSpellRecord(spell_id) orelse return 0;
const dur_idx = hook.readMem(u32, rec + SPELL_DURATION_IDX);
if (readDbRecord(SPELL_DURATION_RECORDS, SPELL_DURATION_MAX_ID, dur_idx)) |dur_rec| {
const base_dur: i32 = @bitCast(hook.readMem(u32, dur_rec + 4));
return if (base_dur > 0) @intCast(base_dur) else 0;
}
return 0;
}
fn isTradeSkillSpell(spell_id: u32) bool {
const rec = getSpellRecord(spell_id) orelse return false;
var i: u32 = 0;
while (i < 3) : (i += 1) {
if (hook.readMem(u32, rec + SPELL_EFFECT_BASE + i * 4) == 47) return true; // SPELL_EFFECT_TRADE_SKILL
}
return false;
}
/// Descriptor byte offset for UNIT_CHANNEL_SPELL (absolute index 0x90)
const DESC_CHANNEL_SPELL: u32 = 0x90 * 4; // = 0x240
// Ring buffer for recent damage events — used by SPELL_AURA_BROKEN heuristic.
// Damage packets arrive BEFORE descriptor updates (which trigger aura removal),
// so the buffer is populated by the time auraRemovedDetour checks it.
@@ -270,6 +382,9 @@ const SUB_DAMAGE_SPLIT: [*:0]const u8 = "DAMAGE_SPLIT";
const SUB_SPELL_DISPEL_FAILED: [*:0]const u8 = "SPELL_DISPEL_FAILED";
const SUB_UNIT_DESTROYED: [*:0]const u8 = "UNIT_DESTROYED";
// Loot (novel extension — not in any WoW combat log, but useful for raid logging)
const SUB_LOOT_ITEM: [*:0]const u8 = "LOOT_ITEM";
// Miss type strings (for _MISSED suffix arg)
const MISS_MISS: [*:0]const u8 = "MISS";
const MISS_DODGE: [*:0]const u8 = "DODGE";
@@ -569,9 +684,9 @@ const FLAG_TARGET: u32 = 0x10000;
const FLAG_FOCUS: u32 = 0x20000;
const FLAG_MAINASSIST: u32 = 0x80000;
const PARTY_MEMBER_GUIDS: u32 = 0x00BC75F8 + 8; // party[0] GUID at leader+8 (leader at BC75F8)
const RAID_ROSTER_ARRAY: u32 = 0x00B712A8;
const RAID_MEMBER_COUNT: u32 = 0x00B713E0;
const PARTY_MEMBER_GUIDS: u32 = @intCast(o.PARTY_MEMBER_GUIDS);
const RAID_ROSTER_ARRAY: u32 = @intCast(o.RAID_ROSTER_ARRAY);
const RAID_MEMBER_COUNT: u32 = @intCast(o.RAID_ROSTER_COUNT);
fn computeUnitFlags(guid: u64) u32 {
if (guid == 0) return 0;
@@ -1236,6 +1351,14 @@ fn fireBase(sub: [*:0]const u8, src_guid: u64, dst_guid: u64) void {
signalEvent();
}
/// Fire a loot event: LOOT_ITEM,playerGUID,...,itemID,itemCount
fn fireLootItem(player_guid: u64, item_id: u32, count: u32) void {
cleuBase(SUB_LOOT_ITEM, player_guid, 0);
cleuNum(item_id);
cleuNum(count);
signalEvent();
}
// =============================================================================
// Hook: InitializeGameEngine (0x401570)
// __thiscall(ECX=this), RET 0xC (3 stack params)
@@ -1389,6 +1512,11 @@ fn installHandlerSwaps() void {
else
log.print("Swapped SPELLINSTAKILLLOG (0x32F)\n");
if (!swapHandler(0x166, @intFromPtr(&itemPushResultDetour)))
log.print("FAILED to swap ITEM_PUSH_RESULT (0x166)\n")
else
log.print("Swapped ITEM_PUSH_RESULT (0x166)\n");
log.fmt("installHandlerSwaps: {d} handlers swapped\n", .{swap_count});
}
@@ -2022,6 +2150,48 @@ fn instaKillDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(hook.cc.
return callOriginalHandler(0x32F, unk, opcode, unk2, cds);
}
// =============================================================================
// Hook: Item_HandleItemPushUpdate (via table swap)
// Packet: SMSG_ITEM_PUSH_RESULT (opcode 0x0166)
// Fires: LOOT_ITEM — novel extension, no range limit
// =============================================================================
// Packet format (from server SendNewItem):
// playerGUID(u64), received(u32), created(u32), showInChat(u32),
// bagSlot(u8), itemSlot(u32), itemID(u32), suffixFactor(u32),
// randomPropertyId(u32), count(u32)
fn itemPushResultDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(hook.cc.fastcall) u32 {
asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true });
const saved_read = cdsGetRead(cds);
const player_guid = cdsGet(u64, cds);
const received = cdsGet(u32, cds);
const created = cdsGet(u32, cds);
const show_in_chat = cdsGet(u32, cds);
_ = cdsGet(u8, cds); // bagSlot
_ = cdsGet(u32, cds); // itemSlot
const item_id = cdsGet(u32, cds);
_ = cdsGet(u32, cds); // suffixFactor
_ = cdsGet(u32, cds); // randomPropertyId
const count = cdsGet(u32, cds);
cdsSetRead(cds, saved_read);
// Only fire for actual loot (received=0, created=0, showInChat=1)
// Excludes quest rewards, vendor purchases, crafted items
if (player_guid != null and item_id != null and count != null and
show_in_chat != null and show_in_chat.? != 0 and
received != null and created != null and
received.? == 0 and created.? == 0)
{
log.fmt("LOOT_ITEM: player=0x{X} item={d} x{d}\n", .{ player_guid.?, item_id.?, count.? });
fireLootItem(player_guid.?, item_id.?, count.?);
}
return callOriginalHandler(0x166, unk, opcode, unk2, cds);
}
// =============================================================================
// Hook: PartyKillLogHandler (0x628890)
// Packet: SMSG_PARTYKILLLOG (opcode 0x01F5)
@@ -2072,11 +2242,15 @@ fn spellStartDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(hook.cc
if (opcode == OPCODE_SPELL_START) {
// SPELL_START: timer(u32), then targetMask(u16), [unitTargetPackGUID if flag 0x2]
_ = cdsGet(u32, cds); // timer
const cast_timer = cdsGet(u32, cds) orelse 0;
const target_mask = cdsGet(u16, cds) orelse 0;
if (target_mask & 0x0002 != 0) { // TARGET_FLAG_UNIT
spell_target = cdsGetPackedGuid(cds) orelse 0;
}
// Track cast state for UnitCastingInfo
if (cast_timer > 0) {
setCastState(caster_guid.?, spell_id.?, cast_timer, false);
}
fireSpell(SUB_SPELL_CAST_START, caster_guid.?, spell_target, spell_id.?);
} else {
// SPELL_GO: hit_count(u8), [hitTargetPackGUID...], miss_count(u8), ...
@@ -2102,6 +2276,15 @@ fn spellStartDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(hook.cc
fireSpell(SUB_SPELL_CAST_SUCCESS, caster_guid.?, spell_target, spell_id.?);
// Cast complete — clear cast state, start channel if applicable
clearCastState(caster_guid.?);
if (isChanneledSpell(spell_id.?)) {
const dur = getSpellDurationMs(spell_id.?);
if (dur > 0) {
setCastState(caster_guid.?, spell_id.?, dur, true);
}
}
// Record casts for aura caster inference
var t: u8 = 0;
while (t < hit_target_count) : (t += 1) {
@@ -2153,6 +2336,7 @@ fn castResultDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(hook.cc
if (spell_id != null and status != null and status.? != 0) {
const player_guid = getActivePlayerGuid();
if (player_guid != 0) {
clearCastState(player_guid);
fireSpellStr(SUB_SPELL_CAST_FAILED, player_guid, 0, spell_id.?, "FAILED");
}
}
@@ -2182,6 +2366,7 @@ fn spellFailedOtherDetour(msg_type: u32, cds: u32) callconv(hook.cc.stdcall) ?*a
cdsSetRead(cds, saved_read);
if (caster_guid != null and spell_id != null and spell_id.? != 0) {
clearCastState(caster_guid.?);
// Skip if this is the local player (already handled by CastResultHandler)
const player_guid = getActivePlayerGuid();
if (caster_guid.? != player_guid) {
@@ -3076,3 +3261,123 @@ pub fn luaGetSpellInfo(L: usize) callconv(hook.cc.fastcall) u32 {
return 7; // 7 return values
}
// =============================================================================
// Lua function: UnitCastingInfo(unit)
// Returns: name, rank, displayName, icon, startTime, endTime, isTradeSkill, castID, notInterruptible, spellId
// Times in milliseconds (GetTime()*1000 scale). Returns nil if not casting.
// =============================================================================
fn pushCastInfo(state: lua.State, cs: CastState, push_cast_id: bool) u32 {
const rec = getSpellRecord(cs.spell_id) orelse {
lua.pushnil(state);
return 1;
};
// 1. name
const name = readLocString(rec, SPELL_NAME_BASE);
lua.pushstring(state, name);
// 2. rank
lua.pushstring(state, readLocString(rec, SPELL_RANK_BASE));
// 3. displayName (same as name in vanilla)
lua.pushstring(state, name);
// 4. icon texture path
const icon_id = hook.readMem(u32, rec + SPELL_ICON_ID);
if (readDbRecord(SPELL_ICON_RECORDS, SPELL_ICON_MAX_ID, icon_id)) |icon_rec| {
const tex_ptr = hook.readMem(u32, icon_rec + 4);
if (tex_ptr != 0) {
lua.pushstring(state, @ptrFromInt(tex_ptr));
} else {
lua.pushnil(state);
}
} else {
lua.pushnil(state);
}
// 5. startTime (ms)
lua.pushnumber(state, @floatFromInt(cs.start_ms));
// 6. endTime (ms)
lua.pushnumber(state, @floatFromInt(cs.end_ms));
// 7. isTradeSkill
lua.pushnumber(state, if (isTradeSkillSpell(cs.spell_id)) 1 else 0);
if (push_cast_id) {
// 8. castID (UnitCastingInfo only)
lua.pushnumber(state, @floatFromInt(cast_id_counter));
// 9. notInterruptible (always false in vanilla — all casts are interruptible)
lua.pushnil(state);
// 10. spellId (added in Legion 7.2.5, we include it for addon convenience)
lua.pushnumber(state, @floatFromInt(cs.spell_id));
return 10;
} else {
// 8. notInterruptible (UnitChannelInfo — no castID)
lua.pushnil(state);
// 9. spellId (added in BfA 8.0.1 for UnitChannelInfo)
lua.pushnumber(state, @floatFromInt(cs.spell_id));
return 9;
}
}
pub fn luaUnitCastingInfo(L: usize) callconv(hook.cc.fastcall) u32 {
const state: lua.State = @ptrFromInt(L);
if (lua.gettop(state) < 1) return 0;
const unit_str = lua.tostring(state, 1);
if (unit_str == null) return 0;
const guid = wow.unitGUID(unit_str.?);
if (guid == 0) return 0;
if (getCastState(guid, false)) |cs| {
return pushCastInfo(state, cs, true);
}
lua.pushnil(state);
return 1;
}
// =============================================================================
// Lua function: UnitChannelInfo(unit)
// Returns: name, rank, displayName, icon, startTime, endTime, isTradeSkill, notInterruptible, spellId
// Times in milliseconds (GetTime()*1000 scale). Returns nil if not channeling.
//
// Cross-validates against UNIT_CHANNEL_SPELL descriptor for non-self units.
// =============================================================================
pub fn luaUnitChannelInfo(L: usize) callconv(hook.cc.fastcall) u32 {
const state: lua.State = @ptrFromInt(L);
if (lua.gettop(state) < 1) return 0;
const unit_str = lua.tostring(state, 1);
if (unit_str == null) return 0;
const guid = wow.unitGUID(unit_str.?);
if (guid == 0) return 0;
// Validate channel is still active via descriptor
const obj = wow.getObjectByGUID(guid);
if (obj != 0) {
const m_data = hook.readMem(u32, obj + 0x08);
if (m_data != 0) {
const channel_spell = hook.readMem(u32, m_data + DESC_CHANNEL_SPELL);
if (channel_spell == 0) {
// Descriptor says not channeling — clear stale state
clearCastState(guid);
lua.pushnil(state);
return 1;
}
}
}
if (getCastState(guid, true)) |cs| {
return pushCastInfo(state, cs, false);
}
lua.pushnil(state);
return 1;
}
+13
View File
@@ -138,6 +138,19 @@ pub const IS_IN_WORLD: usize = 0xB4B424;
/// Static array of 8 GUIDs (64 bytes total). Index 0 = Star, 7 = Skull.
pub const RAID_TARGET_ARRAY: usize = 0x00B71368;
// =============================================================================
// Group / Party / Raid
// =============================================================================
/// Party member GUIDs: 4 slots, 8 bytes each (verified from is_player_in_allowed_list @ 0x4e7f70).
pub const PARTY_MEMBER_GUIDS: usize = 0x00BC6F48;
/// Raid roster: array of 40 pointers to roster entries (GUID at +0/+4).
pub const RAID_ROSTER_ARRAY: usize = 0x00B712A8;
/// Raid roster member count.
pub const RAID_ROSTER_COUNT: usize = 0x00B713E0;
// =============================================================================
// Core function addresses
// =============================================================================
+27
View File
@@ -229,6 +229,33 @@ pub fn getLocalPlayer() u32 {
return getObjectByGUID(guid);
}
// =============================================================================
// Group membership (party + raid)
// =============================================================================
/// Check if a GUID is the local player or in the player's party/raid.
pub fn isInGroup(guid: u64) bool {
if (guid == 0) return false;
if (getPlayerGUID() == guid) return true;
// Party: 4 GUID slots at PARTY_MEMBER_GUIDS
for (0..4) |i| {
const member = readGUID(@intCast(o.PARTY_MEMBER_GUIDS + i * 8));
if (member != 0 and member == guid) return true;
}
// Raid roster
const count = hook.readMem(u32, o.RAID_ROSTER_COUNT);
var j: u32 = 0;
while (j < count and j < 40) : (j += 1) {
const entry_ptr = hook.readMem(u32, @intCast(o.RAID_ROSTER_ARRAY + j * 4));
if (entry_ptr == 0) continue;
if (readGUID(entry_ptr) == guid) return true;
}
return false;
}
/// Creature cache at address 0xC0E138 (not a pointer — the object IS at this address).
const CREATURE_CACHE: u32 = 0xC0E138;