From 96687bfaaaa963cd00e592251198ad316f595ef4 Mon Sep 17 00:00:00 2001 From: MarcelineVQ Date: Sat, 28 Mar 2026 11:42:38 -0700 Subject: [PATCH] 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 --- IDEAS.md | 32 +++ src/clickthrough/clickthrough.zig | 14 +- src/clickthrough/portal_filter.zig | 25 +++ src/dpslog/dpslog.zig | 313 ++++++++++++++++++++++++++++- src/offsets.zig | 13 ++ src/wow.zig | 27 +++ 6 files changed, 417 insertions(+), 7 deletions(-) create mode 100644 IDEAS.md create mode 100644 src/clickthrough/portal_filter.zig diff --git a/IDEAS.md b/IDEAS.md new file mode 100644 index 0000000..063d9f0 --- /dev/null +++ b/IDEAS.md @@ -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) diff --git a/src/clickthrough/clickthrough.zig b/src/clickthrough/clickthrough.zig index 1f3b9fc..89e9149 100644 --- a/src/clickthrough/clickthrough.zig +++ b/src/clickthrough/clickthrough.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; diff --git a/src/clickthrough/portal_filter.zig b/src/clickthrough/portal_filter.zig new file mode 100644 index 0000000..939809a --- /dev/null +++ b/src/clickthrough/portal_filter.zig @@ -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); +} diff --git a/src/dpslog/dpslog.zig b/src/dpslog/dpslog.zig index 8ffd51f..79612cd 100644 --- a/src/dpslog/dpslog.zig +++ b/src/dpslog/dpslog.zig @@ -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; +} diff --git a/src/offsets.zig b/src/offsets.zig index 9dad679..8c27b02 100644 --- a/src/offsets.zig +++ b/src/offsets.zig @@ -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 // ============================================================================= diff --git a/src/wow.zig b/src/wow.zig index aca4b9b..c7f2399 100644 --- a/src/wow.zig +++ b/src/wow.zig @@ -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;