Hook WriteFormattedLogMessage to inject session marker before first combat log write

Instead of hooking EnableChatLogging or SignalEvent (which fire too late
or before the log file is open), hook WriteFormattedLogMessage directly.
On the first write to the combat log, prepend COMBATLOG_SESSION with the
player name resolved via the name cache. This guarantees the session
marker is the very first line in the file.

Also add World_HandleLogoutCleanup (0x491180) hook to reset per-session
state on real logout/disconnect (not /reload), and move remove_on_shutdown
cleanup from shutdownDetour to logoutDetour.
This commit is contained in:
MarcelineVQ
2026-03-03 17:38:25 -08:00
parent 049f6cd851
commit 11851c4ece
4 changed files with 130 additions and 84 deletions
+54 -4
View File
@@ -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.
+43 -57
View File
@@ -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| {
-9
View File
@@ -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
// =============================================================================
+33 -14
View File
@@ -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