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:
@@ -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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user