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
+32
View File
@@ -0,0 +1,32 @@
# Ideas
## Transmog Toggle
Add an option to the TW Options menu to disable all transmogs. Works for the
local player (we have the real item objects and can look up base entry IDs from
descriptors). Does NOT work for other players -- the server only sends the
transmogged item entry in VISIBLE_ITEM fields, and the real entry is never
transmitted to other clients. Would need server-side support (e.g. a packet
flag or CVar the server respects) to strip transmogs on other players.
## Minimap Icon Tooltips
Mouseover popups on minimap tracking blips showing NPC/object name and type.
Reference implementation already exists -- check how it's done there.
## Unusable Portal Visual + Click Blocking
Covers summoned ritual objects (type 18: healthstone, summoning portal, ritual
of refreshment/doom) and mage portals (type 22: GAMEOBJECT_TYPE_SPELLCASTER).
Three states to distinguish:
1. **Interactable** -- usable, normal rendering
2. **Not interactable** -- `CallSpellCastHandler` (0x5f8800) returns 0 (wrong faction, not in group)
3. **Interactable but locked** -- `CallSpellCastHandler` returns 1, `GAMEOBJECT_FLAGS & 0x02` (GO_FLAG_LOCKED) set
Goals:
- Prevent clicking GOs in states 2 and 3 (clickthrough module, GO filter pass)
- Render states 2/3 in greyscale or desaturated to visually distinguish from usable ones
Click blocking fits in clickthrough's existing cascade filter (checkObjTypeDetour).
Greyscale rendering needs research into the client's GO model draw path -- may need
to intercept material/texture setup or set a per-object color tint before the draw call.
Module: clickthrough (own file, e.g. portal_filter.zig)
+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;