The remote was previously a distribution-only point for pre-built DLLs. This opens the source. - LICENSE: Unlicense, with a GPL-3.0 carve-out for src/dpslog/WeirdDPSMate (a DPSMate fork that keeps its own license) - README.md replaces the stale internal one with the user-facing docs from DLL_README.md, swapping the 'Why No Source Code?' section for build and layout notes. DLL_README.md is dropped; one README now serves both. - RELEASING.md: drop the trim-the-README-per-release dance and the remote/WeirdUtils/ distribution clone, both obsolete now - gitignore agent/editor scratch, build caches, the vendored WSBT addon, and the WeirdThreat/uwu-logs checkouts (separate upstream repos) - Commit outstanding module work: superweirdo, clickthrough portal visuals, transform44 decompiles, worldmarkers demo presets, tools/
812 lines
31 KiB
Zig
812 lines
31 KiB
Zig
// =============================================================================
|
|
// transmogfix - Transmog update coalescing
|
|
// =============================================================================
|
|
//
|
|
// Fixes death frame drops in WoW 1.12.1 caused by the server's transmog
|
|
// durability workaround (3-packet clear→dur→restore pattern per slot).
|
|
//
|
|
// Three hooks:
|
|
// 1. SetBlock (0x6142E0) - intercepts all field writes, blocks VISIBLE_ITEM
|
|
// clears/restores and captures durability writes
|
|
// 2. RefreshVisualAppearance (0x5fb880) - skips expensive visual refresh
|
|
// when transmog updates are coalesced
|
|
// 3. SceneEnd (0x5a17a0) - processes pending timeouts every frame
|
|
//
|
|
// =============================================================================
|
|
|
|
const std = @import("std");
|
|
const hook = @import("zhook");
|
|
const logging = @import("../logging.zig");
|
|
const offsets = @import("../offsets.zig");
|
|
const wow = @import("../wow.zig");
|
|
|
|
const WINAPI = std.builtin.CallingConvention.winapi;
|
|
|
|
extern "kernel32" fn GetTickCount() callconv(WINAPI) u32;
|
|
extern "kernel32" fn CreateMutexA(lpMutexAttributes: ?*anyopaque, bInitialOwner: i32, lpName: [*:0]const u8) callconv(WINAPI) ?*anyopaque;
|
|
extern "kernel32" fn ReleaseMutex(hMutex: *anyopaque) callconv(WINAPI) i32;
|
|
extern "kernel32" fn CloseHandle(hObject: *anyopaque) callconv(WINAPI) i32;
|
|
extern "kernel32" fn GetLastError() callconv(WINAPI) u32;
|
|
extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32;
|
|
const ERROR_ALREADY_EXISTS: u32 = 183;
|
|
|
|
// =============================================================================
|
|
// Game addresses
|
|
// =============================================================================
|
|
|
|
const ADDR_UpdateInvAlerts: usize = 0x4c7ee0;
|
|
const ADDR_RefreshAppearance: usize = 0x60afb0;
|
|
const ADDR_RefreshEquipmentDisplay: usize = 0x60ABE0;
|
|
const ADDR_SetBlock: usize = 0x6142E0;
|
|
const ADDR_RefreshVisualAppearance: usize = 0x5fb880;
|
|
|
|
// =============================================================================
|
|
// Constants (1.12.1 client)
|
|
// =============================================================================
|
|
|
|
const PLAYER_VISIBLE_ITEM_1_0: u32 = 0x0F8;
|
|
const VISIBLE_ITEM_STRIDE: u32 = 0x0C;
|
|
const ITEM_FIELD_DURABILITY: u32 = 0x2E;
|
|
const PLAYER_FIELD_INV_SLOT_HEAD: u32 = 0x1DA;
|
|
const PLAYER_INV_SLOT_HEAD_BYTES: u32 = PLAYER_FIELD_INV_SLOT_HEAD * 4;
|
|
const OTHER_PLAYER_TIMEOUT_MS: u32 = 100;
|
|
const LOCAL_PLAYER_CLEANUP_MS: u32 = 5000;
|
|
const DISPLAY_ID_BOX: u32 = 4;
|
|
const UNIT_CACHED_MODELDATA_OFFSET: u32 = 0xb34;
|
|
const DISPLAY_INFO_TABLE_PTR: usize = 0x00c0de90;
|
|
|
|
// =============================================================================
|
|
// Game API functions (inline ASM)
|
|
// =============================================================================
|
|
|
|
fn unitGUID(unit_id: [*:0]const u8) u64 {
|
|
return wow.unitGUID(unit_id);
|
|
}
|
|
|
|
fn getObjectByGUID(guid: u64) u32 {
|
|
return wow.getObjectByGUID(guid);
|
|
}
|
|
|
|
fn updateInventoryAlertStates() void {
|
|
const func: *const fn () callconv(.c) void = @ptrFromInt(ADDR_UpdateInvAlerts);
|
|
func();
|
|
}
|
|
|
|
fn refreshAppearanceAndEquipment(unit: u32) void {
|
|
hook.call(fn (u32) callconv(hook.cc.fastcall) void, ADDR_RefreshAppearance, .{unit});
|
|
}
|
|
|
|
fn refreshEquipmentDisplay(unit: u32) void {
|
|
hook.call(fn (u32) callconv(hook.cc.fastcall) void, ADDR_RefreshEquipmentDisplay, .{unit});
|
|
}
|
|
|
|
// =============================================================================
|
|
// State
|
|
// =============================================================================
|
|
|
|
const LocalPending = struct {
|
|
original_visible_item: u32 = 0,
|
|
timestamp: u32 = 0,
|
|
captured_durability: u32 = 0,
|
|
active: bool = false,
|
|
has_durability: bool = false,
|
|
};
|
|
|
|
var g_local_pending: [19]LocalPending = [1]LocalPending{.{}} ** 19;
|
|
var g_local_pending_count: i32 = 0;
|
|
|
|
const OTHER_PENDING_SIZE: u32 = 1031;
|
|
|
|
const OtherPending = struct {
|
|
guid: u64 = 0,
|
|
slot: i32 = 0,
|
|
timestamp: u32 = 0,
|
|
unit_ptr: u32 = 0,
|
|
active: bool = false,
|
|
};
|
|
|
|
var g_other_pending: [OTHER_PENDING_SIZE]OtherPending = [1]OtherPending{.{}} ** OTHER_PENDING_SIZE;
|
|
var g_other_pending_count: i32 = 0;
|
|
|
|
|
|
|
|
const UNIT_CACHE_SIZE: u32 = 64;
|
|
|
|
const UnitVisualState = struct {
|
|
guid: u64 = 0,
|
|
last_seen: u32 = 0,
|
|
visible_items: [19]u32 = .{0} ** 19,
|
|
clear_timestamp: [19]u32 = .{0} ** 19,
|
|
has_pending_clear: bool = false,
|
|
};
|
|
|
|
var g_unit_cache: [UNIT_CACHE_SIZE]UnitVisualState = [1]UnitVisualState{.{}} ** UNIT_CACHE_SIZE;
|
|
|
|
const CachedPlayerState = struct {
|
|
local_guid: u64 = 0,
|
|
player_obj: u32 = 0,
|
|
player_desc: u32 = 0,
|
|
equipped_guids: [19]u64 = .{0} ** 19,
|
|
visible_items: [19]u32 = .{0} ** 19,
|
|
valid: bool = false,
|
|
};
|
|
|
|
var g_cache: CachedPlayerState = .{};
|
|
|
|
const mod_mutex = @import("../mutex.zig");
|
|
|
|
pub const module_name: [*:0]const u8 = "transmogfix";
|
|
|
|
var g_enabled: bool = true;
|
|
var g_initialized: bool = false;
|
|
var g_is_hook_owner: bool = false;
|
|
var log: logging.Logger = .{};
|
|
var g_mutex: ?*anyopaque = null;
|
|
|
|
pub fn isActive() bool {
|
|
return g_is_hook_owner;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Hooks
|
|
// =============================================================================
|
|
|
|
const SetBlockFn = fn (u32, u32, u32) callconv(hook.cc.thiscall) u32;
|
|
const RefreshFn = fn (u32, u32, u32, u32) callconv(hook.cc.thiscall) void;
|
|
const SceneEndFn = fn (u32) callconv(hook.cc.thiscall) void;
|
|
|
|
var set_block_hook: hook.Detour(SetBlockFn) = .{};
|
|
var refresh_hook: hook.Detour(RefreshFn) = .{};
|
|
var scene_end_hook: hook.Detour(SceneEndFn) = .{};
|
|
|
|
// =============================================================================
|
|
// Object manager helpers
|
|
// =============================================================================
|
|
|
|
fn cachePlayerState() bool {
|
|
const local_guid = unitGUID("player");
|
|
if (local_guid == 0) {
|
|
g_cache.valid = false;
|
|
return false;
|
|
}
|
|
|
|
const player_obj = getObjectByGUID(local_guid);
|
|
if (player_obj == 0 or (player_obj & 1) != 0) {
|
|
g_cache.valid = false;
|
|
return false;
|
|
}
|
|
|
|
// Fast path: player object hasn't changed
|
|
if (g_cache.valid and g_cache.player_obj == player_obj) return true;
|
|
|
|
g_cache.valid = false;
|
|
g_cache.local_guid = local_guid;
|
|
g_cache.player_obj = player_obj;
|
|
|
|
g_cache.player_desc = hook.readMem(u32, player_obj + 0x8);
|
|
if (g_cache.player_desc == 0 or (g_cache.player_desc & 1) != 0) return false;
|
|
|
|
for (0..19) |i| {
|
|
const slot: u32 = @intCast(i);
|
|
const adjusted_slot = slot + 5;
|
|
g_cache.equipped_guids[i] = hook.readMem(u64, g_cache.player_desc + PLAYER_INV_SLOT_HEAD_BYTES + adjusted_slot * 8);
|
|
|
|
const field_index = PLAYER_VISIBLE_ITEM_1_0 + (slot * VISIBLE_ITEM_STRIDE);
|
|
g_cache.visible_items[i] = hook.readMem(u32, g_cache.player_desc + field_index * 4);
|
|
}
|
|
|
|
g_cache.valid = true;
|
|
return true;
|
|
}
|
|
|
|
fn getCachedEquippedItemObject(slot: usize) u32 {
|
|
if (slot >= 19) return 0;
|
|
const guid = g_cache.equipped_guids[slot];
|
|
if (guid == 0) return 0;
|
|
return getObjectByGUID(guid);
|
|
}
|
|
|
|
fn writeItemDurabilityDirect(slot: usize, durability: u32) void {
|
|
const item_obj = getCachedEquippedItemObject(slot);
|
|
if (item_obj == 0 or (item_obj & 1) != 0) return;
|
|
const desc = hook.readMem(u32, item_obj + 0x8);
|
|
if (desc == 0 or (desc & 1) != 0) return;
|
|
const dest: [*]u32 = @ptrFromInt(desc);
|
|
dest[ITEM_FIELD_DURABILITY] = durability;
|
|
}
|
|
|
|
fn findSlotForItemGUID(item_guid: u64) i32 {
|
|
if (!g_cache.valid or item_guid == 0) return -1;
|
|
for (0..19) |i| {
|
|
if (g_cache.equipped_guids[i] == item_guid) return @intCast(i);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
fn getUnitGuid(unit: u32) u64 {
|
|
if (unit == 0) return 0;
|
|
const desc_ptr = hook.readMem(u32, unit + 0x8);
|
|
if (desc_ptr == 0 or (desc_ptr & 1) != 0) return 0;
|
|
return hook.readMem(u64, desc_ptr);
|
|
}
|
|
|
|
fn isPlayerGuid(guid: u64) bool {
|
|
const high_type: u16 = @truncate(guid >> 48);
|
|
return high_type == 0x0000;
|
|
}
|
|
|
|
fn isLocalPlayerObject(obj: u32) bool {
|
|
const local_guid = unitGUID("player");
|
|
if (local_guid == 0) return false;
|
|
return getUnitGuid(obj) == local_guid;
|
|
}
|
|
|
|
fn findSlotForItemObject(obj: u32) i32 {
|
|
const local_guid = unitGUID("player");
|
|
if (local_guid == 0) return -1;
|
|
const player_obj = getObjectByGUID(local_guid);
|
|
if (player_obj == 0 or (player_obj & 1) != 0) return -1;
|
|
const player_desc = hook.readMem(u32, player_obj + 0x8);
|
|
if (player_desc == 0 or (player_desc & 1) != 0) return -1;
|
|
|
|
for (0..19) |i| {
|
|
const adjusted_slot: u32 = @intCast(i + 5);
|
|
const equipped_guid = hook.readMem(u64, player_desc + PLAYER_INV_SLOT_HEAD_BYTES + adjusted_slot * 8);
|
|
if (equipped_guid == 0) continue;
|
|
const equipped = getObjectByGUID(equipped_guid);
|
|
if (equipped == obj) return @intCast(i);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
fn readUnitVisibleItem(unit: u32, slot: usize) u32 {
|
|
if (unit == 0 or slot >= 19) return 0;
|
|
const desc = hook.readMem(u32, unit + 0x8);
|
|
if (desc == 0 or (desc & 1) != 0) return 0;
|
|
const field_index = PLAYER_VISIBLE_ITEM_1_0 + (@as(u32, @intCast(slot)) * VISIBLE_ITEM_STRIDE);
|
|
const desc_arr: [*]const u32 = @ptrFromInt(desc);
|
|
return desc_arr[field_index];
|
|
}
|
|
|
|
fn readVisibleItems(unit: u32, out: *[19]u32) void {
|
|
const desc = hook.readMem(u32, unit + 0x8);
|
|
if (desc == 0 or (desc & 1) != 0) {
|
|
out.* = .{0} ** 19;
|
|
return;
|
|
}
|
|
const desc_arr: [*]const u32 = @ptrFromInt(desc);
|
|
for (0..19) |i| {
|
|
const field_index = PLAYER_VISIBLE_ITEM_1_0 + (@as(u32, @intCast(i)) * VISIBLE_ITEM_STRIDE);
|
|
out[i] = desc_arr[field_index];
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Unit visual state cache
|
|
// =============================================================================
|
|
|
|
fn getUnitCache(guid: u64, allocate: bool) ?*UnitVisualState {
|
|
var empty_slot: i32 = -1;
|
|
var oldest_slot: u32 = 0;
|
|
var oldest_time: u32 = 0xFFFFFFFF;
|
|
|
|
for (0..UNIT_CACHE_SIZE) |i| {
|
|
if (g_unit_cache[i].guid == guid) return &g_unit_cache[i];
|
|
if (g_unit_cache[i].guid == 0 and empty_slot < 0) {
|
|
empty_slot = @intCast(i);
|
|
}
|
|
if (g_unit_cache[i].last_seen < oldest_time) {
|
|
oldest_time = g_unit_cache[i].last_seen;
|
|
oldest_slot = @intCast(i);
|
|
}
|
|
}
|
|
|
|
if (!allocate) return null;
|
|
|
|
const slot: u32 = if (empty_slot >= 0) @intCast(empty_slot) else oldest_slot;
|
|
g_unit_cache[slot] = .{};
|
|
g_unit_cache[slot].guid = guid;
|
|
return &g_unit_cache[slot];
|
|
}
|
|
|
|
// =============================================================================
|
|
// Other player hash table
|
|
// =============================================================================
|
|
|
|
fn hashGuidSlot(guid: u64, slot: i32) u32 {
|
|
var h: u64 = guid ^ (@as(u64, @bitCast(@as(i64, slot))) *% 2654435761);
|
|
h ^= h >> 33;
|
|
h *%= 0xff51afd7ed558ccd;
|
|
h ^= h >> 33;
|
|
return @intCast(h % OTHER_PENDING_SIZE);
|
|
}
|
|
|
|
fn findOtherPendingSlot(guid: u64, slot: i32) i32 {
|
|
const idx = hashGuidSlot(guid, slot);
|
|
for (0..32) |probe| {
|
|
const i: u32 = (idx + @as(u32, @intCast(probe))) % OTHER_PENDING_SIZE;
|
|
if (!g_other_pending[i].active) return @intCast(i);
|
|
if (g_other_pending[i].guid == guid and g_other_pending[i].slot == slot) return @intCast(i);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
fn findOtherPendingEntry(guid: u64, slot: i32) i32 {
|
|
const idx = hashGuidSlot(guid, slot);
|
|
for (0..32) |probe| {
|
|
const i: u32 = (idx + @as(u32, @intCast(probe))) % OTHER_PENDING_SIZE;
|
|
if (!g_other_pending[i].active) return -1;
|
|
if (g_other_pending[i].guid == guid and g_other_pending[i].slot == slot) return @intCast(i);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Calling originals (inline ASM)
|
|
// =============================================================================
|
|
|
|
fn callOriginalSetBlock(obj: u32, index: u32, value: u32) u32 {
|
|
return set_block_hook.callOriginal(.{ obj, index, value });
|
|
}
|
|
|
|
fn callOriginalRefresh(unit: u32, event_data: u32, extra_data: u32, force_update: u32) void {
|
|
refresh_hook.callOriginal(.{ unit, event_data, extra_data, force_update });
|
|
}
|
|
|
|
fn callOriginalSceneEnd(device: u32) void {
|
|
scene_end_hook.callOriginal(.{device});
|
|
}
|
|
|
|
// =============================================================================
|
|
// Timeout processing
|
|
// =============================================================================
|
|
|
|
fn processTimeouts(now: u32) void {
|
|
// LOCAL PLAYER: Safety-net cleanup for stuck pending state
|
|
if (g_local_pending_count > 0) {
|
|
for (0..19) |slot| {
|
|
if (g_local_pending[slot].active) {
|
|
const elapsed = now -% g_local_pending[slot].timestamp;
|
|
if (elapsed >= LOCAL_PLAYER_CLEANUP_MS) {
|
|
g_local_pending[slot].active = false;
|
|
g_local_pending[slot].has_durability = false;
|
|
g_local_pending_count -= 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// OTHER PLAYERS: Use timeout since we don't have their INV_SLOT info
|
|
if (g_other_pending_count > 0 and set_block_hook.inner.trampoline != 0) {
|
|
const UnitSlots = struct {
|
|
unit: u32 = 0,
|
|
slots: [19]i32 = .{0} ** 19,
|
|
slot_count: u32 = 0,
|
|
};
|
|
var units_to_update: [16]UnitSlots = [1]UnitSlots{.{}} ** 16;
|
|
var unit_count: u32 = 0;
|
|
const active_count = g_other_pending_count;
|
|
var found_count: i32 = 0;
|
|
|
|
for (0..OTHER_PENDING_SIZE) |i| {
|
|
if (found_count >= active_count) break;
|
|
if (g_other_pending[i].active) {
|
|
found_count += 1;
|
|
const elapsed = now -% g_other_pending[i].timestamp;
|
|
if (elapsed >= OTHER_PLAYER_TIMEOUT_MS) {
|
|
// Re-resolve the GUID to a live object pointer - the cached
|
|
// unit_ptr may be stale if the player despawned since capture.
|
|
const unit = getObjectByGUID(g_other_pending[i].guid);
|
|
log.fmt("TIMEOUT slot={d:2} guid=0x{X:0>16} {d}ms unit=0x{X:0>8}\n", .{ g_other_pending[i].slot, g_other_pending[i].guid, elapsed, unit });
|
|
if (unit != 0 and (unit & 1) == 0) {
|
|
const field_index = PLAYER_VISIBLE_ITEM_1_0 + (@as(u32, @intCast(g_other_pending[i].slot)) * VISIBLE_ITEM_STRIDE);
|
|
_ = callOriginalSetBlock(unit, field_index, 0);
|
|
|
|
// Track unit for visual update
|
|
var unit_idx: i32 = -1;
|
|
for (0..unit_count) |j| {
|
|
if (units_to_update[j].unit == unit) {
|
|
unit_idx = @intCast(j);
|
|
break;
|
|
}
|
|
}
|
|
if (unit_idx < 0 and unit_count < 16) {
|
|
unit_idx = @intCast(unit_count);
|
|
units_to_update[unit_count].unit = unit;
|
|
units_to_update[unit_count].slot_count = 0;
|
|
unit_count += 1;
|
|
}
|
|
if (unit_idx >= 0) {
|
|
const uidx: u32 = @intCast(unit_idx);
|
|
if (units_to_update[uidx].slot_count < 19) {
|
|
units_to_update[uidx].slots[units_to_update[uidx].slot_count] = g_other_pending[i].slot;
|
|
units_to_update[uidx].slot_count += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
g_other_pending[i].active = false;
|
|
g_other_pending_count -= 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process each unit: invalidate model cache and call RefreshEquipmentDisplay
|
|
for (0..unit_count) |i| {
|
|
const unit = units_to_update[i].unit;
|
|
|
|
// Get the ModelData pointer for the BOX display ID
|
|
// CreatureDisplayInfo table: two derefs from fixed address
|
|
// displayTable = *(u32*)0xc0de90 → READ 1
|
|
// boxModelData = displayTable[4] → READ 2
|
|
const display_table = hook.readMem(u32, DISPLAY_INFO_TABLE_PTR);
|
|
if (display_table != 0) {
|
|
const box_model_data = hook.readMem(u32, display_table + DISPLAY_ID_BOX * 4);
|
|
if (box_model_data != 0) {
|
|
// Set cached ModelData to BOX model - forces ShouldUpdateDisplayInfo = true
|
|
const dest: *u32 = @ptrFromInt(unit + UNIT_CACHED_MODELDATA_OFFSET);
|
|
dest.* = box_model_data;
|
|
|
|
log.fmt("REFRESH unit=0x{X:0>8} table=0x{X:0>8} boxModel=0x{X:0>8}\n", .{ unit, display_table, box_model_data });
|
|
refreshEquipmentDisplay(unit);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Fallback to RefreshVisualAppearance
|
|
log.fmt("REFRESH fallback unit=0x{X:0>8} table=0x{X:0>8}\n", .{ unit, display_table });
|
|
if (refresh_hook.inner.trampoline != 0) {
|
|
callOriginalRefresh(unit, 0, 0, 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Hook 1: SetBlock (0x6142E0)
|
|
// =============================================================================
|
|
|
|
fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(hook.cc.thiscall) u32 {
|
|
const val = value;
|
|
|
|
// VISIBLE_ITEM writes
|
|
if (index >= PLAYER_VISIBLE_ITEM_1_0 and
|
|
index < PLAYER_VISIBLE_ITEM_1_0 + 19 * VISIBLE_ITEM_STRIDE)
|
|
{
|
|
const offset = index - PLAYER_VISIBLE_ITEM_1_0;
|
|
if (offset % VISIBLE_ITEM_STRIDE == 0) {
|
|
const slot: usize = @intCast(offset / VISIBLE_ITEM_STRIDE);
|
|
|
|
if (!g_cache.valid) _ = cachePlayerState();
|
|
|
|
const now = GetTickCount();
|
|
|
|
if (g_enabled and isLocalPlayerObject(obj)) {
|
|
// =========== LOCAL PLAYER ===========
|
|
if (!g_cache.valid or g_cache.player_obj != obj) {
|
|
_ = cachePlayerState();
|
|
}
|
|
// Read live descriptor — g_cached_visible_item can be stale if
|
|
// another DLL (e.g. VanillaHelpers) writes directly to the
|
|
// descriptor, bypassing SetBlock and our hook.
|
|
const current_visible = readUnitVisibleItem(obj, slot);
|
|
if (val == 0 and current_visible != 0) {
|
|
// CLEAR detected - check if INV_SLOT is already empty (real unequip)
|
|
if (g_cache.valid and g_cache.equipped_guids[slot] == 0) {
|
|
return callOriginalSetBlock(obj, index, value);
|
|
}
|
|
|
|
// Start transmog pattern tracking
|
|
if (!g_local_pending[slot].active) g_local_pending_count += 1;
|
|
g_local_pending[slot].original_visible_item = current_visible;
|
|
g_local_pending[slot].timestamp = now;
|
|
g_local_pending[slot].active = true;
|
|
g_local_pending[slot].has_durability = false;
|
|
log.fmt("BLOCK clear slot={d:2} item=0x{X:0>8}\n", .{ slot, current_visible });
|
|
return 1; // Block the clear
|
|
} else if (val != 0 and g_local_pending[slot].active) {
|
|
if (val == g_local_pending[slot].original_visible_item) {
|
|
// RESTORE with same value - transmog pattern confirmed
|
|
|
|
if (g_local_pending[slot].has_durability) {
|
|
const dur = g_local_pending[slot].captured_durability;
|
|
if (dur != 0) {
|
|
writeItemDurabilityDirect(slot, dur);
|
|
log.fmt("APPLY dur slot={d:2} dur={d}\n", .{ slot, dur });
|
|
} else {
|
|
// Don't block - broken items need visual update
|
|
log.fmt("PASS broken slot={d:2}\n", .{slot});
|
|
g_local_pending[slot].active = false;
|
|
g_local_pending[slot].has_durability = false;
|
|
g_local_pending_count -= 1;
|
|
return callOriginalSetBlock(obj, index, value);
|
|
}
|
|
}
|
|
g_local_pending[slot].active = false;
|
|
g_local_pending[slot].has_durability = false;
|
|
g_local_pending_count -= 1;
|
|
|
|
updateInventoryAlertStates();
|
|
|
|
log.fmt("BLOCK restore slot={d:2} item=0x{X:0>8} -- coalesced!\n", .{ slot, val });
|
|
return 1; // Block the restore
|
|
} else {
|
|
// Different item value - real gear change
|
|
g_local_pending[slot].active = false;
|
|
g_local_pending[slot].has_durability = false;
|
|
g_local_pending_count -= 1;
|
|
}
|
|
}
|
|
|
|
} else if (g_enabled) {
|
|
// =========== OTHER PLAYERS ===========
|
|
const guid = getUnitGuid(obj);
|
|
if (guid != 0 and isPlayerGuid(guid)) {
|
|
const idx = findOtherPendingEntry(guid, @intCast(slot));
|
|
|
|
if (val == 0) {
|
|
// CLEAR detected
|
|
const current_val = readUnitVisibleItem(obj, slot);
|
|
if (current_val != 0) {
|
|
const new_idx = findOtherPendingSlot(guid, @intCast(slot));
|
|
if (new_idx >= 0) {
|
|
const ni: u32 = @intCast(new_idx);
|
|
if (!g_other_pending[ni].active) {
|
|
g_other_pending_count += 1;
|
|
}
|
|
g_other_pending[ni].guid = guid;
|
|
g_other_pending[ni].slot = @intCast(slot);
|
|
g_other_pending[ni].timestamp = now;
|
|
g_other_pending[ni].unit_ptr = obj;
|
|
g_other_pending[ni].active = true;
|
|
log.fmt("BLOCK clear slot={d:2} guid=0x{X:0>16}\n", .{ slot, guid });
|
|
return 1; // Block the clear
|
|
}
|
|
}
|
|
} else if (idx >= 0 and g_other_pending[@intCast(idx)].active) {
|
|
const ui: u32 = @intCast(idx);
|
|
// Restore - check timeout and same item
|
|
const elapsed = now -% g_other_pending[ui].timestamp;
|
|
const current_val = readUnitVisibleItem(obj, slot);
|
|
if (elapsed < OTHER_PLAYER_TIMEOUT_MS and val == current_val) {
|
|
g_other_pending[ui].active = false;
|
|
g_other_pending_count -= 1;
|
|
log.fmt("BLOCK restore slot={d:2} guid=0x{X:0>16} {d}ms -- coalesced!\n", .{ slot, guid, elapsed });
|
|
return 1; // Block the restore
|
|
} else {
|
|
g_other_pending[ui].active = false;
|
|
g_other_pending_count -= 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// DURABILITY writes - capture for pending local player slots
|
|
if (g_enabled and index == ITEM_FIELD_DURABILITY) {
|
|
const slot = findSlotForItemObject(obj);
|
|
if (slot >= 0) {
|
|
const s: usize = @intCast(slot);
|
|
if (g_local_pending[s].active) {
|
|
g_local_pending[s].captured_durability = val;
|
|
g_local_pending[s].has_durability = true;
|
|
g_local_pending[s].timestamp = GetTickCount();
|
|
log.fmt("CATCH dur slot={d:2} dur={d}\n", .{ s, val });
|
|
return 1; // Block - captured
|
|
}
|
|
}
|
|
}
|
|
|
|
// INV_SLOT writes - detect gear changes and update cache
|
|
if (index >= PLAYER_FIELD_INV_SLOT_HEAD and
|
|
index < PLAYER_FIELD_INV_SLOT_HEAD + 48)
|
|
{
|
|
const offset = index - PLAYER_FIELD_INV_SLOT_HEAD;
|
|
const inv_index: i32 = @intCast(offset / 2);
|
|
const is_low_word = (offset % 2 == 0);
|
|
const equip_slot = inv_index - 5;
|
|
|
|
if (g_enabled and isLocalPlayerObject(obj) and equip_slot >= 0 and equip_slot < 19) {
|
|
const es: usize = @intCast(equip_slot);
|
|
if (g_cache.valid) {
|
|
if (is_low_word) {
|
|
const current = g_cache.equipped_guids[es];
|
|
g_cache.equipped_guids[es] = (current & 0xFFFFFFFF00000000) | val;
|
|
} else {
|
|
const current = g_cache.equipped_guids[es];
|
|
g_cache.equipped_guids[es] = (current & 0x00000000FFFFFFFF) | (@as(u64, val) << 32);
|
|
}
|
|
}
|
|
|
|
// Low word clear with pending VISIBLE_ITEM block = REAL UNEQUIP
|
|
if (is_low_word and val == 0 and g_local_pending[es].active) {
|
|
log.fmt("REAL UNEQUIP slot={d:2} -- replaying blocked clear\n", .{es});
|
|
const field_index = PLAYER_VISIBLE_ITEM_1_0 + (@as(u32, @intCast(es)) * VISIBLE_ITEM_STRIDE);
|
|
_ = callOriginalSetBlock(obj, field_index, 0);
|
|
|
|
g_local_pending[es].active = false;
|
|
g_local_pending[es].has_durability = false;
|
|
g_local_pending_count -= 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return callOriginalSetBlock(obj, index, value);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Hook 2: RefreshVisualAppearance (0x5fb880)
|
|
// =============================================================================
|
|
|
|
fn hookRefreshVisualAppearance(unit: u32, event_data: u32, extra_data: u32, force_update: u32) callconv(hook.cc.thiscall) void {
|
|
if (!g_enabled or refresh_hook.inner.trampoline == 0) {
|
|
if (refresh_hook.inner.trampoline != 0) {
|
|
callOriginalRefresh(unit, event_data, extra_data, force_update);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const guid = getUnitGuid(unit);
|
|
if (guid == 0 or !isPlayerGuid(guid)) {
|
|
callOriginalRefresh(unit, event_data, extra_data, force_update);
|
|
return;
|
|
}
|
|
|
|
// LOCAL PLAYER: If we have pending SetBlock blocks, skip expensive refresh
|
|
if (g_cache.valid and guid == g_cache.local_guid and g_local_pending_count > 0) {
|
|
log.fmt("SKIP RefreshVisualAppearance (pending={d})\n", .{g_local_pending_count});
|
|
refreshAppearanceAndEquipment(unit);
|
|
const flags1: *u32 = @ptrFromInt(unit + 0xccc);
|
|
const flags2: *u32 = @ptrFromInt(unit + 0xcd0);
|
|
flags1.* = 1;
|
|
flags2.* = 1;
|
|
return;
|
|
}
|
|
|
|
const now = GetTickCount();
|
|
|
|
var current_items: [19]u32 = undefined;
|
|
readVisibleItems(unit, ¤t_items);
|
|
|
|
const state = getUnitCache(guid, true) orelse {
|
|
callOriginalRefresh(unit, event_data, extra_data, force_update);
|
|
return;
|
|
};
|
|
state.last_seen = now;
|
|
|
|
// Analyze changes
|
|
var cleared_slots: i32 = 0;
|
|
var restored_slots: i32 = 0;
|
|
var all_restores_within_timeout = true;
|
|
|
|
for (0..19) |s| {
|
|
const cached = state.visible_items[s];
|
|
const current = current_items[s];
|
|
|
|
if (cached != current) {
|
|
if (current == 0 and cached != 0) {
|
|
cleared_slots += 1;
|
|
state.clear_timestamp[s] = now;
|
|
state.has_pending_clear = true;
|
|
} else if (current != 0 and cached == 0 and state.clear_timestamp[s] != 0) {
|
|
const elapsed = now -% state.clear_timestamp[s];
|
|
if (elapsed < OTHER_PLAYER_TIMEOUT_MS) {
|
|
restored_slots += 1;
|
|
} else {
|
|
all_restores_within_timeout = false;
|
|
}
|
|
state.clear_timestamp[s] = 0;
|
|
} else if (current != 0 and cached == 0) {
|
|
all_restores_within_timeout = false;
|
|
} else {
|
|
all_restores_within_timeout = false;
|
|
state.clear_timestamp[s] = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update cache
|
|
state.visible_items = current_items;
|
|
|
|
// Update hasPendingClear
|
|
state.has_pending_clear = false;
|
|
for (0..19) |s| {
|
|
if (state.clear_timestamp[s] != 0) {
|
|
const elapsed = now -% state.clear_timestamp[s];
|
|
if (elapsed >= OTHER_PLAYER_TIMEOUT_MS) {
|
|
state.clear_timestamp[s] = 0;
|
|
} else {
|
|
state.has_pending_clear = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
const should_skip = (restored_slots > 0) and (cleared_slots == 0) and all_restores_within_timeout;
|
|
|
|
if (should_skip) {
|
|
log.fmt("SKIP RefreshVisualAppearance restored={d} guid=0x{X:0>16}\n", .{ restored_slots, guid });
|
|
refreshAppearanceAndEquipment(unit);
|
|
const flags1: *u32 = @ptrFromInt(unit + 0xccc);
|
|
const flags2: *u32 = @ptrFromInt(unit + 0xcd0);
|
|
flags1.* = 1;
|
|
flags2.* = 1;
|
|
|
|
if (g_cache.valid and guid == g_cache.local_guid) {
|
|
updateInventoryAlertStates();
|
|
}
|
|
return;
|
|
}
|
|
|
|
callOriginalRefresh(unit, event_data, extra_data, force_update);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Hook 3: SceneEnd (0x5a17a0)
|
|
// =============================================================================
|
|
|
|
fn hookSceneEnd(device: u32) callconv(hook.cc.thiscall) void {
|
|
if (g_enabled and (g_local_pending_count > 0 or g_other_pending_count > 0)) {
|
|
processTimeouts(GetTickCount());
|
|
}
|
|
|
|
callOriginalSceneEnd(device);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Init / Cleanup
|
|
// =============================================================================
|
|
|
|
pub fn installHooks() void {
|
|
|
|
// Legacy mutex name - this DLL existed in the wild before the naming convention
|
|
const result = mod_mutex.acquireLegacy("TransmogCoalesceHook", module_name);
|
|
g_mutex = result.handle;
|
|
g_is_hook_owner = result.is_owner;
|
|
if (!g_is_hook_owner) {
|
|
g_initialized = true;
|
|
return;
|
|
}
|
|
log = logging.Logger.open(module_name, .console);
|
|
|
|
// Initialize state (already zero-initialized by Zig defaults)
|
|
g_local_pending = [1]LocalPending{.{}} ** 19;
|
|
g_local_pending_count = 0;
|
|
g_other_pending = [1]OtherPending{.{}} ** OTHER_PENDING_SIZE;
|
|
g_other_pending_count = 0;
|
|
g_cache = .{};
|
|
g_unit_cache = [1]UnitVisualState{.{}} ** UNIT_CACHE_SIZE;
|
|
|
|
// Hook 1: SetBlock
|
|
if (set_block_hook.attach(ADDR_SetBlock, &hookSetBlock) != .ok) return;
|
|
|
|
// Hook 2: RefreshVisualAppearance
|
|
if (refresh_hook.attach(ADDR_RefreshVisualAppearance, &hookRefreshVisualAppearance) != .ok) {
|
|
set_block_hook.detach();
|
|
return;
|
|
}
|
|
|
|
// Hook 3: SceneEnd
|
|
if (scene_end_hook.attach(offsets.FN_SCENE_END, &hookSceneEnd) != .ok) {
|
|
refresh_hook.detach();
|
|
set_block_hook.detach();
|
|
return;
|
|
}
|
|
|
|
g_initialized = true;
|
|
log.print("All 3 hooks installed\n");
|
|
}
|
|
|
|
pub fn removeHooks() void {
|
|
if (g_is_hook_owner) {
|
|
scene_end_hook.detach();
|
|
refresh_hook.detach();
|
|
set_block_hook.detach();
|
|
log.close();
|
|
mod_mutex.release(&g_mutex);
|
|
}
|
|
|
|
g_initialized = false;
|
|
g_is_hook_owner = false;
|
|
}
|