Files
WeirdUtils/src/combatlog/RESEARCH.md
T
MarcelineVQ 12db928365 Hook InitializeLogBuffer to redirect combat log paths from any caller
SuperWoWhook.dll calls InitializeLogBuffer directly with hardcoded
"Logs\WoWCombatLog.txt", bypassing the path pointer table. Hook
InitializeLogBuffer itself to intercept the path argument and
substitute our timestamped filename, regardless of caller. Also
redirects WoWRawCombatLog.txt to a matching timestamped file.

TODO: session marker (COMBATLOG_SESSION) needs to be written as the
first line in each log file -- currently it appears after SuperWoW's
initial writes (COMBATANT_INFO, ZONE_INFO) because the write hook
fires too late.

TODO: consolidate small/empty WoWCombatLog_*.txt files on startup.
2026-03-04 08:44:40 -08:00

16 KiB

Combat Log Research

Goal

Session-unique combat log files with player login markers, robust against rapid client open/close and multiple simultaneous clients.

Approach

  • Overwrite string pointer at 0x00843610 to redirect combat log to timestamped+PID filename
  • Hook EnableChatLogging (0x0049fe50) to inject session marker line on combat log enable
  • Use Lua PLAYER_ENTERING_WORLD event hook to inject player login lines
  • Optional: cleanup pass on startup for tiny/stale files

String Table Layout

The combat log path is stored in a static pointer table in .data (WRITABLE):

0x0084360c -> 0x008441ec = "Logs\WoWChatLog.txt"     (index 0)
0x00843610 -> 0x008441d4 = "Logs\WoWCombatLog.txt"   (index 1)

Both the pointer table AND the string data are in .data section (0x00827000-0x00882fff), which is R/W. No VirtualProtect needed.

Section Map

.text   0x00401000-0x007fefff  R-X
.rdata  0x007ff000-0x00826fff  R--
.data   0x00827000-0x00882fff  RW-  <-- pointer table + strings are HERE
.data   0x00883000-0x00cfa153  RW-  <-- BSS (runtime globals)

Global Arrays (BSS, runtime)

  • PTR_00b4fdb4[index] -- enable flags (0=disabled, nonzero=enabled)
    • [0] = chat log enabled (0x00b4fdb4)
    • [1] = combat log enabled (0x00b4fdb8)
  • PTR_00b50540[index] -- log buffer handles (returned by InitializeLogBuffer)
    • [0] = chat log handle (0x00b50540)
    • [1] = combat log handle (0x00b50544)

Key Functions

EnableChatLogging (0x0049fe50) -- __fastcall(ECX=lua_State, EDX=index)

Lua C function called as LoggingCombat(bool) / LoggingChat(bool). param_2 (EDX) selects which log: 0=chat, 1=combat.

EnableChatLogging(lua_State *L, int index):
  if lua_gettop(L) > 0:
    enabled = LuaValueToBool(L, 1, true)
    enable_flags[index] = enabled
    if enabled and log_handles[index] == NULL:
      CreateDirectoryRecursive("Logs")
      log_handles[index] = InitializeLogBuffer(
          path_table[index],   // "Logs\WoWCombatLog.txt" for index=1
          4,                   // flags: append mode (bit 2 set)
          &log_handles[index])
      if failed: enable_flags[index] = 0
  return enabled ? 1.0 : nil

Disasm confirms __fastcall:

0x0049fe50: PUSH EBX
0x0049fe51: PUSH ESI
0x0049fe52: PUSH EDI
0x0049fe53: MOV ESI,EDX      ; index
0x0049fe55: MOV EDI,ECX      ; lua_State

Access pattern: [ESI*4 + 0xb4fdb4] (enable flags), [ESI*4 + 0xb50540] (handles). Path loaded from: [ESI*4 + 0x0084360c] (path_table).

HOOK TARGET: This is where the path is read and passed to InitializeLogBuffer. We can either:

  1. Overwrite the pointer at 0x00843610 before this runs (simplest)
  2. Or overwrite the string itself at 0x008441d4 (also simple, 21 chars available but we need more -- "Logs\WoWCombatLog_YYYYMMDD_HHMMSS_PPPPP.txt" = ~48 chars)

Since the original string buffer is only 22 bytes ("Logs\WoWCombatLog.txt\0"), we MUST use the pointer redirect approach -- point 0x00843610 to our own static buffer that has enough room for the longer name.

InitializeLogBuffer (0x0065a0c0) -- __cdecl(filePath, flags, *handle)

Creates a log buffer context. The file path string is stored inside the context object at offset +8 (via SafeStringCopy, max 0x104 = 260 bytes). The file handle is at context+0x10c. File opening is lazy (bit 0 not set in flags=4, so EnsureLogFileOpen opens on first write).

CreateLogFile (0x0065a1c0) -- __fastcall(ECX=filePath, stack: *handle, flags)

Opens the actual file via CreateFileA. Uses FILE_SHARE_READ | FILE_SHARE_WRITE (share mode 3). Append mode (flag bit 2) uses OPEN_ALWAYS + SetFilePointer(END).

EnsureLogFileOpen (0x0065a930) -- __fastcall(ECX=context)

Called before each write. If handle is -1, calls CreateLogFile with the stored path from context+0x08. This is the lazy-open path.

WriteFormattedLogMessage (0x0065ac20) -- __stdcall(handle, fmt, va_list)

NOT cdecl variadic. Three fixed params, callee cleans stack (RET 0xC). The third argument is a va_list (pointer to variadic args), not the args themselves. For %s, vsprintf reads *(char**)va_list to get the string pointer.

Disasm evidence:

0x0065ac20: PUSH EBP
0x0065ac21: MOV EBP,ESP
0x0065ac23: MOV ECX,[EBP+0x8]    ; handle
...
0x0065ac5d: MOV EAX,[EBP+0x10]   ; va_list
0x0065ac60: MOV ECX,[EBP+0xC]    ; fmt string
...
0x0065ac69: PUSH EAX              ; va_list -> vsprintf arg3
0x0065ac6a: PUSH ECX              ; fmt -> vsprintf arg2
0x0065ac6b: LEA EAX,[EDX+ESI+0x124]
0x0065ac72: PUSH EAX              ; dest buffer -> vsprintf arg1
0x0065ac73: CALL vsprintf         ; (0x7412c1) -- cdecl internally
0x0065ac78: ADD ESP,0xC           ; vsprintf cleanup (cdecl)
...
0x0065acdc: RET 0xC               ; stdcall -- callee cleans 3 args

Correct calling pattern from DLL:

// va_list must point to where the char* lives in memory
name_ptr = &name_buf;
push &name_ptr    // arg3: va_list (pointer TO the char*)
push fmt_str      // arg2: "COMBATLOG_SESSION,%s"
push handle       // arg1: combat log handle
call WriteFormattedLogMessage
// NO add esp -- callee cleans via RET 0xC

Previous crash: passing &name_buf directly as va_list caused vsprintf to interpret the first 4 bytes of the name ("Munj" = 0x6A6E754D) as a char*, crashing at strlen (0x0074300E: CMP BYTE [EAX], 0).

The main write path:

WriteFormattedLogMessage(handle, formatStr, va_list):
  context = GetLogBufferContext(handle)
  EnsureLogFileOpen(context)
  WriteTimestampToLogLine(context, 1)
  WriteIndentationSpaces(context)
  vsprintf(context_buffer + offset, formatStr, va_list)
  offset += strlen(...)
  WriteLogLineTerminator(context)
  if offset > 0xBFFF: WriteLogBufferToFile(context)  // auto-flush at 48KB

WriteLogEntry (0x0063cb50) -- __fastcall(ECX=text, EDX=param2)

Higher-level: adds text to the in-memory chat log ring buffer AND dispatches to file log buffers.

LogCombatMessage (0x006268f0) -- uses ECX as message type index

Formats combat message strings. Accesses [ESI*4 + 0x862920] for format strings.

Log Buffer Context Structure

+0x000: linked list prev?
+0x004: linked list next?
+0x008: char filePath[260]  (0x104 bytes) -- copied from InitializeLogBuffer arg
+0x10c: HANDLE fileHandle   (context[0x43]) -- INVALID_HANDLE_VALUE until open
+0x110: uint flags           (context[0x44])
+0x114: uint writeOffset     (context[0x45]) -- current position in buffer
+0x118: uint debugOffset     (context[0x46])
+0x11c: uint unknown         (context[0x47])
+0x120: uint active          (context[0x48])
+0x124: char buffer[]        (write buffer, flushed at 48KB)

World Enter Events

  • PLAYER_LOGIN at 0x00852d50 -- event table slot at 0x00be15d0
  • PLAYER_ENTERING_WORLD at 0x00852d28 -- event table slot at 0x00be15d8

These are Lua event name strings in the game's event table. The event IDs are the table indices (PLAYER_LOGIN = slot[0], PLAYER_ENTERING_WORLD = slot[2]).

Player Name Resolution

The markers module already has the pattern:

  • GetPlayerGUID (0x00468550) -- __fastcall(), returns EAX(lo):EDX(hi)
    • Actually ClntObjMgrGetActivePlayer -- returns player object pointer
    • Player object: +0x8 = GUID (8 bytes)
  • RetrieveNPCDataFromCache (0x0055f080) -- __thiscall(ECX=cache_obj), 6 stack params
    • Cache object at 0x00c0e228
    • (guid_lo, guid_hi, name_buf_ptr, 0, 0, 0) -> char* name or NULL
  • getRealmNameConfigValue (0x005ab7d0) -- returns CVar value ptr
    • CVar "realmName" stored at PTR_00c28130+0x20

CVar: combatLogOn

  • String: s_combatLogOn_008430d4
  • Registered in InitializeGameInterface (0x0048fbf0)
  • Stored at global DAT_00b4da38

Implementation Plan

1. Redirect Combat Log Path (session-unique files)

At DLL init time (before any Lua runs):

  1. Format a timestamped path: "Logs\WoWCombatLog_YYYYMMDD_HHMMSS_PID.txt" into a static buffer in our DLL
  2. Overwrite the pointer at 0x00843610 with the address of our buffer

This is race-free because:

  • Each process has its own address space, so each gets its own pointer value
  • The timestamp+PID combo is unique even for rapid client open/close
  • The path is read lazily when LoggingCombat(1) is called from Lua, which happens after UI init -- well after our DLL loads

2. Player Login Line Injection

Two approaches, both viable:

A. Hook EnableChatLogging -- intercept the LoggingCombat(1) call. When index=1 (combat log) and the call succeeds, immediately write a session marker. This requires hooking a __fastcall function (5 bytes at prologue).

B. Lua-side approach -- register for PLAYER_ENTERING_WORLD in Lua and have the handler call WriteFormattedLogMessage via our DLL's exported function. Simpler but requires the combat log to already be enabled.

Recommended: Approach A for the session start marker (guarantees it's the first line), plus Approach B for subsequent world-enter markers (zone changes, logout/login). Use WriteFormattedLogMessage(combat_handle, "COMBATLOG_SESSION,%s,%s") where combat_handle = *(u32*)0x00b50544.

3. Cleanup (phase 2)

On DLL init, scan Logs\ directory for WoWCombatLog_*.txt files:

  • Delete files < 1KB (empty sessions, client crashes before any combat)
  • Optionally compress files older than N days

Resolved Questions

  • Section at 0x00843610: .data (RW) -- no VirtualProtect needed
  • Player name: use GetObjectName (0x6264E0) — __fastcall(ECX=guid_ptr) → char*. RetrieveNPCDataFromCache (0x55f080) does NOT return char* in EAX — it returns name bytes. The actual pointer is written to the output buffer param. GetObjectName is simpler and verified by c_overlay reference.
  • Original string too short for timestamped name -- must use pointer redirect
  • ESI clobber crash (ACCESS_VIOLATION at 0x6F61AF): luaCallFunction stores luaState in ESI and the C function pointer in EDI, dispatches via CALL EDI, then reads [ESI+0x8]. Our detour's compiled code (Debug build) did NOT push ESI/EDI/EBX in its prologue — Zig only saves callee-saved registers it allocates directly, but subcalls (callOriginal wrapper, inline asm game calls) can clobber them without the compiler knowing. All game functions verified to preserve ESI/EDI/EBX: GetPlayerGUID (0x468550, doesn't touch them), GetObjectName (0x6264E0, PUSH/POP ESI), WriteFormattedLogMessage (0x65AC20, PUSH/POP ESI). The clobber originates from Zig's generated code paths, not game functions. Fix: asm volatile ("" ::: .{ .esi=true, .edi=true, .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.

SuperWoW Combat Log Interference

SuperWoWhook.dll has its own Lua C function CombatLogAdd (at 0x10001f50 in the current build) that calls InitializeLogBuffer (0x0065a0c0) directly with hardcoded path strings, bypassing the path pointer table at 0x0084360c entirely.

SuperWoW CombatLogAdd (0x10001f50) logic

CombatLogAdd(lua_State *L):
  raw = LuaValueToBool(L, 2, false)    // arg2: is this a raw log event?
  if raw:
    handle_ptr = 0x1001e4c8            // SuperWoW's own handle (in DLL .bss)
    path = "Logs\WoWRawCombatLog.txt"  // hardcoded at 0x1001af00
  else:
    handle_ptr = 0x00b50544            // WoW's combat log handle
    path = "Logs\WoWCombatLog.txt"     // hardcoded at 0x1001aee8
  if *handle_ptr == 0:
    CreateDirectoryRecursive("Logs")
    InitializeLogBuffer(path, 4, handle_ptr)
  WriteTimestampToLogBuffer(*handle_ptr, fmt, value)
  return 1.0

Why pointer redirect sometimes fails

When SuperWoW's CombatLogAdd fires before the game's LoggingCombat(1):

  1. SuperWoW calls InitializeLogBuffer("Logs\WoWCombatLog.txt", 4, 0x00b50544) using its own hardcoded string -- our pointer at 0x00843610 is never read
  2. Handle at 0x00b50544 becomes non-zero
  3. When EnableChatLogging runs later, it sees handle != 0 and skips init
  4. Result: log file created with default name despite our pointer redirect

The "sometimes works" depends on whether the addon's LoggingCombat(1) or SuperWoW's first CombatLogAdd call fires first.

Fix: Hook InitializeLogBuffer

Instead of overwriting the path pointer, hook InitializeLogBuffer (0x0065a0c0) itself. Intercept the filePath argument and replace:

  • "Logs\WoWCombatLog.txt" -> our timestamped combat log path
  • "Logs\WoWRawCombatLog.txt" -> our timestamped raw combat log path

This works regardless of who calls InitializeLogBuffer (game or SuperWoW).

InitializeLogBuffer is __stdcall with RET 0xC: (filePath, flags, *handleOut). The path is copied into the context struct via SafeStringCopy (max 260 bytes), so substituting a different pointer is safe.

SuperWoW raw log handle

SuperWoW stores its raw combat log handle at 0x1001e4c8 (in SuperWoWhook.dll's .bss section). This is NOT in WoW.exe's handle array -- it's SuperWoW-private. The raw log path "Logs\WoWRawCombatLog.txt" is at 0x1001af00 in the DLL.

ShutdownMessageSystem (0x00659ec0) -- __stdcall(handle), RET 0x4

Proper cleanup for a log buffer handle:

  1. Gets log buffer context from handle
  2. If file handle != -1: flushes (WriteLogBufferToFile), closes (CloseHandle)
  3. Frees context memory (FreeMemoryFromPool)

Called from ShutdownChatSubsystem (0x00498700) which loops both handle slots.

Open Questions

  • 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.
  • 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 now uses RetrieveNPCDataFromCache directly (same pattern as markers) and it works.
  • 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.
  • 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.