Fix WriteFormattedLogMessage: stdcall with va_list, not cdecl variadic

Ghidra disasm confirms RET 0xC (callee cleans 3 args). Third arg is a
va_list pointer, not the variadic args directly. For %s, vsprintf reads
*(char**)va_list -- previous code passed &name_buf causing vsprintf to
interpret "Munj" (0x6A6E754D) as a char* and crash at strlen.

Fix: pass &name_ptr (pointer to the char*) and remove caller stack
cleanup. Added detailed research to RESEARCH.md.
This commit is contained in:
MarcelineVQ
2026-03-03 14:53:32 -08:00
parent aa6ea3c558
commit 11030e0566
3 changed files with 296 additions and 7 deletions
+285
View File
@@ -0,0 +1,285 @@
# 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
- [x] Section at 0x00843610: `.data` (RW) -- no VirtualProtect needed
- [x] 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.
- [x] Original string too short for timestamped name -- must use pointer redirect
- [x] **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.
## 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).
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.
+9 -6
View File
@@ -53,7 +53,7 @@ fn setupPathRedirect() void {
const pid = GetCurrentProcessId();
const path = std.fmt.bufPrint(&g_path_buf, "Logs\\WoWCombatLog_{d:0>4}{d:0>2}{d:0>2}_{d:0>2}{d:0>2}{d:0>2}_{d}.txt", .{
st.wYear, st.wMonth, st.wDay,
st.wYear, st.wMonth, st.wDay,
st.wHour, st.wMinute, st.wSecond,
pid,
}) catch {
@@ -162,18 +162,21 @@ fn maybeWriteSessionMarker() void {
name_local[0..len], @intFromPtr(name), len,
});
const args = [3]u32{ combat_handle, @intFromPtr(@as([*:0]const u8, "COMBATLOG_SESSION,%s")), @intFromPtr(&name_local) };
// 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]
\\ add $12, %%esp
:
: [a] "r" (&args),
[func] "r" (@as(u32, o.FN_WRITE_FMT_LOG_MSG)),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .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]});
@@ -196,7 +199,7 @@ fn enableChatLoggingDetour(lua_state: u32, index: u32) callconv(fc) u32 {
// 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 });
asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true });
const result = enable_logging_hook.callOriginal(.{ lua_state, index });
// index 1 = combat log
+2 -1
View File
@@ -17,7 +17,8 @@ pub const COMBAT_LOG_HANDLE: usize = 0x00b50544;
// Log writing
// =============================================================================
/// WriteFormattedLogMessage — __cdecl(handle: u32, fmt: [*:0]const u8, ...).
/// WriteFormattedLogMessage — __stdcall(handle: u32, fmt: [*:0]const u8, va_list: *anyopaque).
/// Three fixed params, callee cleans stack (RET 0xC). Third arg is va_list pointer.
/// Writes a timestamped, formatted line to the log buffer. Auto-flushes at 48KB.
pub const FN_WRITE_FMT_LOG_MSG: usize = 0x0065ac20;