diff --git a/src/combatlog/RESEARCH.md b/src/combatlog/RESEARCH.md index 9d7e09b..52f35db 100644 --- a/src/combatlog/RESEARCH.md +++ b/src/combatlog/RESEARCH.md @@ -269,17 +269,67 @@ On DLL init, scan `Logs\` directory for `WoWCombatLog_*.txt` files: .ebx=true })` barrier at top of detour forces Zig to push/pop in prologue/epilogue regardless of internal register allocation. +## Logout Detection + +`CGGameUI_Shutdown` (0x490BD0) fires on `/reload` AND logout -- unsuitable for +per-session reset. The correct logout-only function is: + +### World_HandleLogoutCleanup (0x491180) -- __stdcall(), no params + +Only called from `ShutdownClientSystems` (0x401ee0), which is called from: +- `NetworkDisconnectHandler` (0x46c540) +- `handleDisconnectWithReason` (0x5aad70) +- `cleanupAfterDisconnect` (0x5aaeb0) + +These are all real disconnect/logout paths. NOT called on `/reload` or map change. + +`World_HandleLogoutCleanup` calls `World_HandlePlayerLogin` (0x490BD0) as its +first action, which fires `SignalEvent(271)` (PLAYER_LOGOUT). + +Full cleanup sequence: World_HandlePlayerLogin (fires PLAYER_LOGOUT event), +UI_ProcessDirtyFaces, release_minimap_textures, ShutdownChatSubsystem, +SpellBookManager_Cleanup, CleanupUnitSystem, UnregisterPlayerStateHandlers, etc. + +Hooked in `main.zig` as `logout_hook` -- available to all modules via `onLogout()`. + +### Event IDs (verified via event table at 0xbe1198) + +- PLAYER_LOGIN = 270 (table slot 0xbe15d0, string at 0x852d50) +- PLAYER_LOGOUT = 271 (table slot 0xbe15d4, string at 0x852d40) +- PLAYER_ENTERING_WORLD = 272 (table slot 0xbe15d8, string at 0x852d28) +- LOGOUT_CANCEL = event at table slot 0xbe15f0, string at 0x852cc4 + +### SignalEvent callers for login/logout events + +- `SignalEvent(270)` PLAYER_LOGIN: from `CGGameUI::EnterWorld` (0x4908c0) +- `SignalEvent(271)` PLAYER_LOGOUT: from `World_HandlePlayerLogin` (0x490bd0) +- `SignalEvent(272)` PLAYER_ENTERING_WORLD: from `CGGameUI::EnterWorld` (0x4908c0) +- `SignalEvent(273)`: from `World_HandleEnterWorldCleanup` (0x490a80) + +### Player Name Resolution + +`GetObjectPtr` (0x464870) -> `GetUnitName` (0x609210) does NOT work during early +login -- player object not in object manager yet. Use name cache instead: +`RetrieveNPCDataFromCache` (0x55f080, cache at 0xc0e228) is populated before the +object manager and works for the local player GUID. + ## Open Questions - [ ] Where does SuperWoW write `WoWRawCombatLog.txt`? Need to check SuperWoWhook.dll. Same redirect approach should work if it uses the same table or a similar one. -- [ ] Exact prologue size of `EnableChatLogging` for hooking (need 5+ bytes). +- [x] Exact prologue size of `EnableChatLogging` for hooking (need 5+ bytes). First 3 instructions = PUSH EBX; PUSH ESI; PUSH EDI = 3 bytes. Then MOV ESI,EDX = 2 bytes. Total = 5 bytes -- just enough for a jmp hook. - [x] **Markers permission check**: markers' `getNameFromGUID` uses `RetrieveNPCDataFromCache` (0x55f080) and works correctly for roster GUIDs. The combatlog crash (EAX=name bytes instead of char*) is specific to the - context — either the player's own GUID is handled differently, or the cache - state during `EnableChatLogging` is incomplete. Combatlog uses `GetObjectName` - (0x6264E0) instead, which avoids the issue. No change needed for markers. + context -- either the player's own GUID is handled differently, or the cache + state during `EnableChatLogging` is incomplete. Combatlog now uses + RetrieveNPCDataFromCache directly (same pattern as markers) and it works. +- [x] **Player name at EnableChatLogging time**: name cache IS populated when + LoggingCombat(1) fires. The GetObjectPtr path failed because the object + manager isn't ready, not because the name isn't known. +- [x] **Session marker ordering**: Hooking WriteFormattedLogMessage (0x65ac20) + directly guarantees our marker is the first line -- intercepts the first + write to the combat log handle and prepends the session marker. diff --git a/src/combatlog/combatlog.zig b/src/combatlog/combatlog.zig index 4cdd316..f0e9841 100644 --- a/src/combatlog/combatlog.zig +++ b/src/combatlog/combatlog.zig @@ -124,18 +124,35 @@ fn getNameFromGUID(guid_lo: u32, guid_hi: u32) ?[*:0]const u8 { } // ============================================================================= -// Session marker +// Session marker state // ============================================================================= var g_session_marker_written: bool = false; -/// Write a COMBATLOG_SESSION line with the player's name to the combat log. -/// Called once after combat logging is first enabled for this session. -fn maybeWriteSessionMarker() void { - // Read combat log handle — 0 means log not active yet - const combat_handle = hook.readMem(u32, o.COMBAT_LOG_HANDLE); - if (combat_handle == 0) return; +// ============================================================================= +// WriteFormattedLogMessage hook — inject session marker on first combat log write +// ============================================================================= +const sc = std.builtin.CallingConvention{ .x86_stdcall = .{} }; + +var write_log_hook: hook.Detour(fn (u32, u32, u32) callconv(sc) void) = .{}; + +fn writeLogDetour(handle: u32, fmt: u32, va_list: u32) callconv(sc) void { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + // On first write to combat log, prepend our session marker + if (!g_session_marker_written) { + const combat_handle = hook.readMem(u32, o.COMBAT_LOG_HANDLE); + if (handle == combat_handle and combat_handle != 0) { + writeSessionMarkerNow(handle); + } + } + + write_log_hook.callOriginal(.{ handle, fmt, va_list }); +} + +/// Resolve player name and write COMBATLOG_SESSION via callOriginal. +fn writeSessionMarkerNow(handle: u32) void { const player_guid = getPlayerGUID(); if (player_guid == 0) return; const guid_lo: u32 = @truncate(player_guid); @@ -146,63 +163,32 @@ fn maybeWriteSessionMarker() void { return; }; - // Copy name to a stack buffer — the name cache pointer can be - // invalidated by game-side log writes. + // Copy name to stack buffer — cache pointer can be invalidated by the write var name_local: [49]u8 = undefined; const name_span = std.mem.span(name); const len = @min(name_span.len, name_local.len - 1); @memcpy(name_local[0..len], name_span[0..len]); name_local[len] = 0; - con.fmt("[combatlog] player name: '{s}' (ptr=0x{x:0>8}, len={d})\n", .{ - name_local[0..len], @intFromPtr(name), len, + // va_list for %s: pointer to a char* + const name_ptr: u32 = @intFromPtr(&name_local); + write_log_hook.callOriginal(.{ + handle, + @intFromPtr(@as([*:0]const u8, "COMBATLOG_SESSION: %s")), + @intFromPtr(&name_ptr), }); - // WriteFormattedLogMessage — __stdcall(handle, fmt, va_list). - // RET 0xC: callee cleans 12 bytes (3 args), no caller cleanup needed. - // arg3 (va_list) is a pointer to the variadic args on the stack. - // For %s, vsprintf reads *(char**)va_list, so va_list must point to a char*. - const name_ptr: u32 = @intFromPtr(&name_local); - const args = [3]u32{ combat_handle, @intFromPtr(@as([*:0]const u8, "COMBATLOG_SESSION: %s")), @intFromPtr(&name_ptr) }; - asm volatile ( - \\ push 8(%[a]) - \\ push 4(%[a]) - \\ push (%[a]) - \\ call *%[func] - : - : [a] "r" (&args), - [func] "r" (@as(u32, o.FN_WRITE_FMT_LOG_MSG)), - : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }); - g_session_marker_written = true; - con.fmt("[combatlog] session: {s}\n", .{name_local[0..len]}); + con.fmt("[combatlog] session marker written: {s}\n", .{name_local[0..len]}); } -// ============================================================================= -// EnableChatLogging hook -// ============================================================================= - -const fc = std.builtin.CallingConvention{ .x86_fastcall = .{} }; - -var enable_logging_hook: hook.Detour(fn (u32, u32) callconv(fc) u32) = .{}; - -fn enableChatLoggingDetour(lua_state: u32, index: u32) callconv(fc) u32 { - // The Lua VM's luaCallFunction (0x6F6050) stores luaState in ESI and the - // C function pointer in EDI, dispatches via CALL EDI, then reads [ESI+0x8] - // expecting callee-saved registers preserved. Zig may not push ESI/EDI/EBX - // in this function's prologue if it doesn't allocate them itself, but subcalls - // (callOriginal wrapper, inline asm game calls) can clobber them without the - // compiler knowing. This barrier forces Zig to push/pop ESI/EDI/EBX in the - // prologue/epilogue and avoid using them for intermediates — guaranteeing - // they're correctly restored on return to the Lua VM. - asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); - - const result = enable_logging_hook.callOriginal(.{ lua_state, index }); - // index 1 = combat log - if (index == 1 and !g_session_marker_written) { - maybeWriteSessionMarker(); +/// Resets session marker so the next login's first write gets a new marker. +/// Called from both logoutDetour (real logout) and shutdownDetour (logout/exit/reload). +pub fn onShutdown() void { + if (g_session_marker_written) { + g_session_marker_written = false; + con.print("[combatlog] session marker reset\n"); } - return result; } // ============================================================================= @@ -232,17 +218,17 @@ pub fn installHooks() void { // Redirect combat log path to timestamped+PID filename setupPathRedirect(); - // Hook EnableChatLogging to inject session marker on combat log enable - if (enable_logging_hook.attach(o.FN_ENABLE_CHAT_LOGGING, &enableChatLoggingDetour) != .ok) { - con.print("[combatlog] FAILED to hook EnableChatLogging!\n"); + // Hook WriteFormattedLogMessage to inject session marker before the first combat log write + if (write_log_hook.attach(o.FN_WRITE_FMT_LOG_MSG, &writeLogDetour) != .ok) { + con.print("[combatlog] FAILED to hook WriteFormattedLogMessage!\n"); } else { - con.print("[combatlog] hooked EnableChatLogging OK\n"); + con.print("[combatlog] hooked WriteFormattedLogMessage OK\n"); } } pub fn removeHooks() void { if (g_is_hook_owner) { - enable_logging_hook.detach(); + write_log_hook.detach(); restorePathPointer(); if (g_mutex) |m| { diff --git a/src/combatlog/offsets.zig b/src/combatlog/offsets.zig index 294389a..f3682b2 100644 --- a/src/combatlog/offsets.zig +++ b/src/combatlog/offsets.zig @@ -22,15 +22,6 @@ pub const COMBAT_LOG_HANDLE: usize = 0x00b50544; /// Writes a timestamped, formatted line to the log buffer. Auto-flushes at 48KB. pub const FN_WRITE_FMT_LOG_MSG: usize = 0x0065ac20; -// ============================================================================= -// Combat log enable -// ============================================================================= - -/// EnableChatLogging — __fastcall(ECX=lua_State, EDX=index). -/// index 0=chat, 1=combat. Called by LoggingCombat()/LoggingChat() Lua functions. -/// Reads path from path_table[index] and opens log file on first enable. -pub const FN_ENABLE_CHAT_LOGGING: usize = 0x0049fe50; - // ============================================================================= // Player identity // ============================================================================= diff --git a/src/main.zig b/src/main.zig index 04dd2c6..eec7819 100644 --- a/src/main.zig +++ b/src/main.zig @@ -780,6 +780,34 @@ fn engineInitDetour() callconv(sc) void { } } +// ============================================================================= +// Hook: World_HandleLogoutCleanup (0x491180) +// Fires on real character logout/disconnect only — NOT on /reload or map change. +// ============================================================================= + +var logout_hook: hook.Detour(fn () callconv(sc) void) = .{}; + +fn logoutDetour() callconv(sc) void { + con.print("[weirdutils] World_HandleLogoutCleanup -- player logout\n"); + + // Reset per-session state — only on real logout/disconnect, not /reload. + if (build_opts.worldmarkers) markers.onShutdown(); + if (build_opts.combatlog) combatlog.onShutdown(); + + // Clean up world objects BEFORE game teardown — modules with + // remove_on_shutdown must destroy while game systems are alive. + comptime var i = modules.len; + inline while (i > 0) { + i -= 1; + const m = modules[i]; + if (m.remove_on_shutdown) { + if (m.remove) |rm| rm(); + } + } + + logout_hook.callOriginal(.{}); +} + // ============================================================================= // Hook: CGGameUI_Shutdown (0x490BD0) // ============================================================================= @@ -816,20 +844,9 @@ const modules = [_]ModuleHooks{ }; fn shutdownDetour() callconv(sc) void { - // Clear marker definitions on logout/exit (not on map change). - if (build_opts.worldmarkers) markers.onShutdown(); - - // Clean up world objects BEFORE game shutdown — atexit handlers run before - // DllMain so modules with remove_on_shutdown must destroy here. - comptime var i = modules.len; - inline while (i > 0) { - i -= 1; - const m = modules[i]; - if (m.remove_on_shutdown) { - if (m.remove) |rm| rm(); - } - } - + con.print("[weirdutils] CGGameUI_Shutdown\n"); + // Per-session resets and remove_on_shutdown cleanup live in logoutDetour + // (World_HandleLogoutCleanup) — fires on real logout/disconnect only, not /reload. shutdown_hook.callOriginal(.{}); } @@ -851,11 +868,13 @@ fn install() void { _ = load_addons_hook.attach(0x51F600, &loadAddonsDetour); _ = engine_init_hook.attach(0x46a400, &engineInitDetour); + _ = logout_hook.attach(0x491180, &logoutDetour); _ = shutdown_hook.attach(0x490BD0, &shutdownDetour); } fn uninstall() void { shutdown_hook.detach(); + logout_hook.detach(); engine_init_hook.detach(); // Remove in reverse order