Module control API: - Three cdecl exports: WeirdUtils_IsModuleActive, WeirdUtils_DisableModule, WeirdUtils_DisableAll - C header include/weirdutils_api.h for runtime DLL discovery - All modules gain pub module_name, isActive(), shared mutex via src/mutex.zig - build.zig refactored to module_list array of ModuleDesc Bigcursor: - D3D9 vtable hooks on SetCursorProperties/ShowCursor - Scale2x/Scale3x pixel-art upscalers in pure Zig (replaces 12K-line hqx C) - Bilinear resampler for fractional scales (1.0-4.0, default 1.2) - Win32 cursor via CreateIconIndirect bypasses D3D9 32x32 limit - FNV-1a hash cache for ~16 cursor bitmaps - Lua API: SetCursorScale(n) / GetCursorScale() - CVar cursorScale for Config.wtf persistence (tenths) Other: - Add dpslog module stub - Docs and README updates
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.