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.
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
0x00843610to redirect combat log to timestamped+PID filename - Hook
EnableChatLogging(0x0049fe50) to inject session marker line on combat log enable - Use Lua
PLAYER_ENTERING_WORLDevent 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:
- Overwrite the pointer at
0x00843610before this runs (simplest) - 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_LOGINat 0x00852d50 -- event table slot at 0x00be15d0PLAYER_ENTERING_WORLDat 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)
- Actually
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):
- Format a timestamped path:
"Logs\WoWCombatLog_YYYYMMDD_HHMMSS_PID.txt"into a static buffer in our DLL - Overwrite the pointer at
0x00843610with 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: fromCGGameUI::EnterWorld(0x4908c0)SignalEvent(271)PLAYER_LOGOUT: fromWorld_HandlePlayerLogin(0x490bd0)SignalEvent(272)PLAYER_ENTERING_WORLD: fromCGGameUI::EnterWorld(0x4908c0)SignalEvent(273): fromWorld_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):
- SuperWoW calls
InitializeLogBuffer("Logs\WoWCombatLog.txt", 4, 0x00b50544)using its own hardcoded string -- our pointer at 0x00843610 is never read - Handle at 0x00b50544 becomes non-zero
- When
EnableChatLoggingruns later, it sees handle != 0 and skips init - 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:
- Gets log buffer context from handle
- If file handle != -1: flushes (WriteLogBufferToFile), closes (CloseHandle)
- Frees context memory (FreeMemoryFromPool)
Called from ShutdownChatSubsystem (0x00498700) which loops both handle slots.
Open Questions
- Exact prologue size of
EnableChatLoggingfor 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'
getNameFromGUIDusesRetrieveNPCDataFromCache(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 duringEnableChatLoggingis 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.