Add bigcursor module stub, rename combatlog to logsessions, bump session timer to 60min

- New bigcursor module skeleton with build integration and mutex
- Rename combatlog module to logsessions (directory, files, build options,
  imports, console prefixes, mutex name, DLL variant, docs)
- Increase log session continuation threshold from 30 to 60 minutes
- Update DLL_README log sessions section
This commit is contained in:
MarcelineVQ
2026-03-04 15:14:04 -08:00
parent 1bbf334958
commit 00749d5745
9 changed files with 141 additions and 64 deletions
+396
View File
@@ -0,0 +1,396 @@
# 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.
## 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
- [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 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.
+562
View File
@@ -0,0 +1,562 @@
//! Log session rotation with per-character directories.
//!
//! Redirects combat log, raw combat log, and chat log to per-character
//! directories: `Logs\<realm>\<character>\<Type>_<timestamp>_<PID>.txt`.
//!
//! Features:
//! - Early path setup: hooks HandleCharacterSelection to resolve character/realm
//! from the select screen data before world loading begins
//! - Session continuation: reuses files modified < 60 min ago
//! - Session marker: writes `COMBATLOG_SESSION: <char> <realm>` on first combat write
//!
//! All DLL-side — no Lua addon needed.
const std = @import("std");
const hook = @import("zhook");
const con = @import("../console.zig");
const lua = @import("../lua.zig");
const o = @import("offsets.zig");
const sc = std.builtin.CallingConvention{ .x86_stdcall = .{} };
const WINAPI = std.builtin.CallingConvention.winapi;
// =============================================================================
// Win32 imports
// =============================================================================
extern "kernel32" fn CreateMutexA(lpMutexAttributes: ?*anyopaque, bInitialOwner: i32, lpName: [*:0]const u8) callconv(WINAPI) ?*anyopaque;
extern "kernel32" fn ReleaseMutex(hMutex: *anyopaque) callconv(WINAPI) i32;
extern "kernel32" fn CloseHandle(hObject: *anyopaque) callconv(WINAPI) i32;
extern "kernel32" fn GetLastError() callconv(WINAPI) u32;
extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32;
extern "kernel32" fn GetLocalTime(lpSystemTime: *SYSTEMTIME) callconv(WINAPI) void;
extern "kernel32" fn GetSystemTimeAsFileTime(lpSystemTimeAsFileTime: *FILETIME) callconv(WINAPI) void;
extern "kernel32" fn CreateDirectoryA(lpPathName: [*:0]const u8, lpSecurityAttributes: ?*anyopaque) callconv(WINAPI) i32;
extern "kernel32" fn FindFirstFileA(lpFileName: [*:0]const u8, lpFindFileData: *WIN32_FIND_DATAA) callconv(WINAPI) usize;
extern "kernel32" fn FindNextFileA(hFindFile: usize, lpFindFileData: *WIN32_FIND_DATAA) callconv(WINAPI) i32;
extern "kernel32" fn FindClose(hFindFile: usize) callconv(WINAPI) i32;
const ERROR_ALREADY_EXISTS: u32 = 183;
const INVALID_HANDLE_VALUE: usize = 0xFFFFFFFF;
const FILETIME = extern struct {
dwLowDateTime: u32,
dwHighDateTime: u32,
};
const SYSTEMTIME = extern struct {
wYear: u16,
wMonth: u16,
wDayOfWeek: u16,
wDay: u16,
wHour: u16,
wMinute: u16,
wSecond: u16,
wMilliseconds: u16,
};
const WIN32_FIND_DATAA = extern struct {
dwFileAttributes: u32,
ftCreationTime: FILETIME,
ftLastAccessTime: FILETIME,
ftLastWriteTime: FILETIME,
nFileSizeHigh: u32,
nFileSizeLow: u32,
dwReserved0: u32,
dwReserved1: u32,
cFileName: [260]u8,
cAlternateFileName: [14]u8,
};
// =============================================================================
// Mutex state
// =============================================================================
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
// =============================================================================
// Session state — reset on logout
// =============================================================================
/// Static buffers for redirected paths (null-terminated). Must outlive the process.
var g_combat_path: [260]u8 = undefined;
var g_raw_combat_path: [260]u8 = undefined;
var g_chat_path: [260]u8 = undefined;
var g_combat_path_len: usize = 0;
var g_raw_combat_path_len: usize = 0;
var g_chat_path_len: usize = 0;
/// Directory prefix: `Logs\<realm>\<char>\`
var g_dir_path: [280]u8 = undefined;
var g_dir_path_len: usize = 0;
/// Stored character and realm names (sanitized: spaces → underscores)
var g_session_char: [64]u8 = undefined;
var g_session_realm: [64]u8 = undefined;
var g_session_char_len: usize = 0;
var g_session_realm_len: usize = 0;
/// True once paths have been configured for this login session.
var g_paths_configured: bool = false;
/// Per-log-type session marker flags.
var g_combat_marker_written: bool = false;
var g_chat_marker_written: bool = false;
var g_raw_marker_written: bool = false;
/// Raw combat log handle address — captured from initLogDetour when SuperWoW
/// calls InitializeLogBuffer for WoWRawCombatLog. We don't know this address
/// statically; SuperWoW passes it as handle_out.
var g_raw_combat_handle_addr: u32 = 0;
/// Saved original path pointers for restoration.
var g_original_combat_path_ptr: u32 = 0;
var g_original_chat_path_ptr: u32 = 0;
// =============================================================================
// Character / realm identity
// =============================================================================
/// Read realm name from CVar: dereference base pointer, then read string at +0x20.
fn getRealmName() ?[*:0]const u8 {
const base = hook.readMem(u32, o.REALM_NAME_CVAR_BASE);
if (base == 0) return null;
const str_addr = hook.readMem(u32, base + 0x20);
if (str_addr == 0) return null;
return @ptrFromInt(str_addr);
}
/// Read the selected character's name from the character select screen data.
/// Available when the player clicks Enter World (before world loading begins).
fn getCharSelectName() ?[*:0]const u8 {
const index = hook.readMem(i32, o.CHAR_SELECT_INDEX);
if (index < 0) return null;
const count = hook.readMem(i32, o.CHAR_LIST_COUNT);
if (index >= count) return null;
const list_base = hook.readMem(u32, o.CHAR_LIST_BASE);
if (list_base == 0) return null;
const entry_addr = list_base + @as(u32, @intCast(index)) * o.CHAR_ENTRY_SIZE + o.CHAR_NAME_OFFSET;
// Name is a C string embedded in the entry struct (not a pointer)
return @ptrFromInt(entry_addr);
}
// =============================================================================
// Name sanitization and directory creation
// =============================================================================
/// Copy name to dst, replacing spaces with underscores. Returns length written.
fn sanitizeName(src: []const u8, dst: []u8) usize {
const len = @min(src.len, dst.len);
for (0..len) |i| {
dst[i] = if (src[i] == ' ') '_' else src[i];
}
return len;
}
/// Create a single directory level, ignoring ERROR_ALREADY_EXISTS.
fn ensureDir(path: [*:0]const u8) bool {
if (CreateDirectoryA(path, null) != 0) return true;
return GetLastError() == ERROR_ALREADY_EXISTS;
}
/// Build and create `Logs\<realm>\<char>\` directory tree.
/// Stores the full directory prefix in g_dir_path. Returns true on success.
fn setupSessionDir(realm: []const u8, char_name: []const u8) bool {
// Logs\ (may already exist)
if (!ensureDir("Logs")) return false;
// Logs\<realm>
var realm_dir: [280]u8 = undefined;
const realm_path = std.fmt.bufPrint(&realm_dir, "Logs\\{s}", .{realm}) catch return false;
realm_dir[realm_path.len] = 0;
if (!ensureDir(@ptrCast(realm_dir[0..realm_path.len :0]))) return false;
// Logs\<realm>\<char> (CreateDirectoryA doesn't want trailing backslash)
var char_dir: [280]u8 = undefined;
const char_path = std.fmt.bufPrint(&char_dir, "Logs\\{s}\\{s}", .{ realm, char_name }) catch return false;
char_dir[char_path.len] = 0;
if (!ensureDir(@ptrCast(char_dir[0..char_path.len :0]))) return false;
// Store with trailing backslash for path concatenation
const dir_path = std.fmt.bufPrint(&g_dir_path, "Logs\\{s}\\{s}\\", .{ realm, char_name }) catch return false;
g_dir_path[dir_path.len] = 0;
g_dir_path_len = dir_path.len;
con.fmt("[logsessions] dir: {s}\n", .{g_dir_path[0..g_dir_path_len]});
return true;
}
// =============================================================================
// Session continuation — find recent file to reuse
// =============================================================================
/// Scan directory for files matching `<prefix>_*.txt`, return the newest if
/// modified within 60 minutes. Writes full path into result_buf, returns length.
fn findRecentFile(prefix: []const u8, result_buf: *[260]u8) ?usize {
if (g_dir_path_len == 0) return null;
// Build search pattern: <dir><prefix>_*.txt
var search_buf: [300]u8 = undefined;
const pattern = std.fmt.bufPrint(&search_buf, "{s}{s}_*.txt", .{
g_dir_path[0..g_dir_path_len],
prefix,
}) catch return null;
search_buf[pattern.len] = 0;
var find_data: WIN32_FIND_DATAA = undefined;
const find_handle = FindFirstFileA(@ptrCast(search_buf[0..pattern.len :0]), &find_data);
if (find_handle == INVALID_HANDLE_VALUE) return null;
defer _ = FindClose(find_handle);
var newest_time: u64 = 0;
var newest_name: [260]u8 = undefined;
var newest_name_len: usize = 0;
// Iterate all matching files, track the newest by write time
var has_result: bool = true;
while (has_result) {
const ft: u64 = @bitCast(find_data.ftLastWriteTime);
if (ft > newest_time) {
newest_time = ft;
const name_len = std.mem.indexOfScalar(u8, &find_data.cFileName, 0) orelse 0;
if (name_len > 0) {
newest_name_len = name_len;
@memcpy(newest_name[0..name_len], find_data.cFileName[0..name_len]);
}
}
has_result = FindNextFileA(find_handle, &find_data) != 0;
}
if (newest_name_len == 0) return null;
// Compare against current time — both UTC FILETIME (100ns units)
var current_ft: FILETIME = undefined;
GetSystemTimeAsFileTime(&current_ft);
const current: u64 = @bitCast(current_ft);
const threshold: u64 = 60 * 60 * 10_000_000; // 60 minutes
if (current > newest_time and (current - newest_time) < threshold) {
// Build full path: dir + filename
const total_len = g_dir_path_len + newest_name_len;
if (total_len >= result_buf.len) return null;
@memcpy(result_buf[0..g_dir_path_len], g_dir_path[0..g_dir_path_len]);
@memcpy(result_buf[g_dir_path_len..total_len], newest_name[0..newest_name_len]);
result_buf[total_len] = 0;
return total_len;
}
return null;
}
/// Resolve a log file path: reuse recent file or generate new timestamped name.
fn resolveLogPath(prefix: []const u8, result_buf: *[260]u8) usize {
// Try to reuse a recent file (modified < 60 min ago)
if (findRecentFile(prefix, result_buf)) |len| {
con.fmt("[logsessions] reusing: {s}\n", .{result_buf[0..len]});
return len;
}
// Generate new timestamped filename
var st: SYSTEMTIME = undefined;
GetLocalTime(&st);
const path = std.fmt.bufPrint(result_buf, "{s}{s}_{d:0>4}{d:0>2}{d:0>2}_{d:0>2}{d:0>2}{d:0>2}.txt", .{
g_dir_path[0..g_dir_path_len],
prefix,
st.wYear,
st.wMonth,
st.wDay,
st.wHour,
st.wMinute,
st.wSecond,
}) catch return 0;
result_buf[path.len] = 0;
con.fmt("[logsessions] new: {s}\n", .{path});
return path.len;
}
// =============================================================================
// Session path configuration
// =============================================================================
/// Core path setup: sanitize names, create directories, resolve all log paths.
/// Called from enterWorldDetour when the player clicks Enter World.
fn configureSession(char_span: []const u8, realm_span: []const u8) void {
// Sanitize and store names
g_session_char_len = sanitizeName(char_span, &g_session_char);
g_session_realm_len = sanitizeName(realm_span, &g_session_realm);
con.fmt("[logsessions] session: {s} on {s}\n", .{
g_session_char[0..g_session_char_len],
g_session_realm[0..g_session_realm_len],
});
// Create directory tree: Logs\<realm>\<char>\
if (!setupSessionDir(
g_session_realm[0..g_session_realm_len],
g_session_char[0..g_session_char_len],
)) {
con.print("[logsessions] setup: failed to create directories\n");
return;
}
// Resolve paths for all three log types
g_combat_path_len = resolveLogPath("WoWCombatLog", &g_combat_path);
g_raw_combat_path_len = resolveLogPath("WoWRawCombatLog", &g_raw_combat_path);
g_chat_path_len = resolveLogPath("WoWChatLog", &g_chat_path);
// Belt-and-suspenders: overwrite path pointer table for game code paths
// that read the table directly before calling InitializeLogBuffer.
if (g_combat_path_len > 0) {
g_original_combat_path_ptr = hook.readMem(u32, o.COMBAT_LOG_PATH_PTR);
const ptr_bytes: [4]u8 = @bitCast(@intFromPtr(&g_combat_path));
hook.writeMem(o.COMBAT_LOG_PATH_PTR, &ptr_bytes);
}
if (g_chat_path_len > 0) {
g_original_chat_path_ptr = hook.readMem(u32, o.CHAT_LOG_PATH_PTR);
const ptr_bytes: [4]u8 = @bitCast(@intFromPtr(&g_chat_path));
hook.writeMem(o.CHAT_LOG_PATH_PTR, &ptr_bytes);
}
g_paths_configured = true;
}
// =============================================================================
// HandleCharacterSelection hook — set up paths before world loading
// =============================================================================
var enter_world_hook: hook.Detour(fn () callconv(sc) void) = .{};
/// Fires when the player clicks Enter World on the character select screen.
/// Character name and realm are available from the select screen data.
/// Sets up paths BEFORE the world loading sequence calls InitializeLogBuffer.
fn enterWorldDetour() callconv(sc) void {
asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true });
if (!g_paths_configured) {
const char_name = getCharSelectName();
const realm_name = getRealmName();
if (char_name != null and realm_name != null) {
configureSession(std.mem.span(char_name.?), std.mem.span(realm_name.?));
} else {
con.print("[logsessions] enter world: char/realm not available\n");
}
}
enter_world_hook.callOriginal(.{});
}
fn restorePathPointers() void {
if (g_original_combat_path_ptr != 0) {
const ptr_bytes: [4]u8 = @bitCast(g_original_combat_path_ptr);
hook.writeMem(o.COMBAT_LOG_PATH_PTR, &ptr_bytes);
g_original_combat_path_ptr = 0;
}
if (g_original_chat_path_ptr != 0) {
const ptr_bytes: [4]u8 = @bitCast(g_original_chat_path_ptr);
hook.writeMem(o.CHAT_LOG_PATH_PTR, &ptr_bytes);
g_original_chat_path_ptr = 0;
}
}
// =============================================================================
// InitializeLogBuffer hook — lazy setup + path redirect
// =============================================================================
var init_log_hook: hook.Detour(fn (u32, u32, u32) callconv(sc) u32) = .{};
fn initLogDetour(file_path: u32, flags: u32, handle_out: u32) callconv(sc) u32 {
asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true });
const path_ptr: [*:0]const u8 = @ptrFromInt(file_path);
const path_span = std.mem.span(path_ptr);
// Redirect based on path suffix
if (g_combat_path_len > 0 and std.mem.endsWith(u8, path_span, "WoWCombatLog.txt")) {
con.fmt("[logsessions] redirect: {s} -> {s}\n", .{ path_span, g_combat_path[0..g_combat_path_len] });
return init_log_hook.callOriginal(.{ @intFromPtr(&g_combat_path), flags, handle_out });
}
if (g_raw_combat_path_len > 0 and std.mem.endsWith(u8, path_span, "WoWRawCombatLog.txt")) {
g_raw_combat_handle_addr = handle_out; // capture SuperWoW's handle address
con.fmt("[logsessions] redirect: {s} -> {s}\n", .{ path_span, g_raw_combat_path[0..g_raw_combat_path_len] });
return init_log_hook.callOriginal(.{ @intFromPtr(&g_raw_combat_path), flags, handle_out });
}
if (g_chat_path_len > 0 and std.mem.endsWith(u8, path_span, "WoWChatLog.txt")) {
con.fmt("[logsessions] redirect: {s} -> {s}\n", .{ path_span, g_chat_path[0..g_chat_path_len] });
return init_log_hook.callOriginal(.{ @intFromPtr(&g_chat_path), flags, handle_out });
}
return init_log_hook.callOriginal(.{ file_path, flags, handle_out });
}
// =============================================================================
// WriteFormattedLogMessage hook — inject session markers on first write per log
// =============================================================================
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 });
if (g_session_char_len > 0) {
if (!g_combat_marker_written) {
const combat_handle = hook.readMem(u32, o.COMBAT_LOG_HANDLE);
if (handle == combat_handle and combat_handle != 0) {
writeSessionMarker(handle, "COMBATLOG_SESSION: %s");
g_combat_marker_written = true;
}
}
if (!g_chat_marker_written) {
const chat_handle = hook.readMem(u32, o.CHAT_LOG_HANDLE);
if (handle == chat_handle and chat_handle != 0) {
writeSessionMarker(handle, "CHAT_SESSION: %s");
g_chat_marker_written = true;
}
}
if (!g_raw_marker_written and g_raw_combat_handle_addr != 0) {
const raw_handle = hook.readMem(u32, g_raw_combat_handle_addr);
if (handle == raw_handle and raw_handle != 0) {
writeSessionMarker(handle, "COMBATLOG_SESSION: %s");
g_raw_marker_written = true;
}
}
}
write_log_hook.callOriginal(.{ handle, fmt, va_list });
}
/// Write a session marker line using stored character + realm names.
fn writeSessionMarker(handle: u32, fmt_str: [*:0]const u8) void {
var marker_buf: [128]u8 = undefined;
const marker_str = std.fmt.bufPrint(&marker_buf, "{s} {s}", .{
g_session_char[0..g_session_char_len],
g_session_realm[0..g_session_realm_len],
}) catch return;
marker_buf[marker_str.len] = 0;
const marker_ptr: u32 = @intFromPtr(&marker_buf);
write_log_hook.callOriginal(.{
handle,
@intFromPtr(fmt_str),
@intFromPtr(&marker_ptr),
});
con.fmt("[logsessions] marker: {s}\n", .{marker_str});
}
// =============================================================================
// Shutdown — reset session state for next login
// =============================================================================
/// Resets all session state so the next login gets fresh paths.
/// Called from logoutDetour/shutdownDetour in main.zig.
pub fn onShutdown() void {
if (g_paths_configured) {
con.print("[logsessions] session reset\n");
}
g_paths_configured = false;
g_combat_marker_written = false;
g_chat_marker_written = false;
g_raw_marker_written = false;
g_raw_combat_handle_addr = 0;
g_combat_path_len = 0;
g_raw_combat_path_len = 0;
g_chat_path_len = 0;
g_dir_path_len = 0;
g_session_char_len = 0;
g_session_realm_len = 0;
// Restore original path pointers so next session starts clean
restorePathPointers();
}
// =============================================================================
// Lua API — log path accessors
// =============================================================================
/// GetCombatLogPath() → string or nil
pub fn luaGetCombatLogPath(L: lua.State) callconv(.c) u32 {
if (g_combat_path_len > 0) {
lua.pushstring(L, @ptrCast(g_combat_path[0..g_combat_path_len :0]));
} else {
lua.pushnil(L);
}
return 1;
}
/// GetChatLogPath() → string or nil
pub fn luaGetChatLogPath(L: lua.State) callconv(.c) u32 {
if (g_chat_path_len > 0) {
lua.pushstring(L, @ptrCast(g_chat_path[0..g_chat_path_len :0]));
} else {
lua.pushnil(L);
}
return 1;
}
// =============================================================================
// Install / remove hooks
// =============================================================================
pub fn installHooks() void {
con.print("[logsessions] Module loaded\n");
// Multi-DLL safety: only one instance per process should hook
var mutex_name_buf: [64]u8 = undefined;
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_LogSessionsHook_{d}", .{GetCurrentProcessId()}) catch return;
mutex_name_buf[mutex_name.len] = 0;
g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0]));
if (g_mutex == null) return;
if (GetLastError() == ERROR_ALREADY_EXISTS) {
_ = CloseHandle(g_mutex.?);
g_mutex = null;
g_is_hook_owner = false;
con.print("[logsessions] Another DLL owns hooks (mutex taken), skipping\n");
return;
}
g_is_hook_owner = true;
// Hook HandleCharacterSelection — sets up paths when player clicks Enter World,
// before the world loading sequence calls InitializeLogBuffer.
if (enter_world_hook.attach(o.FN_HANDLE_CHAR_SELECT, &enterWorldDetour) != .ok) {
con.print("[logsessions] FAILED to hook HandleCharacterSelection!\n");
} else {
con.print("[logsessions] hooked HandleCharacterSelection OK\n");
}
if (init_log_hook.attach(o.FN_INIT_LOG_BUFFER, &initLogDetour) != .ok) {
con.print("[logsessions] FAILED to hook InitializeLogBuffer!\n");
} else {
con.print("[logsessions] hooked InitializeLogBuffer OK\n");
}
if (write_log_hook.attach(o.FN_WRITE_FMT_LOG_MSG, &writeLogDetour) != .ok) {
con.print("[logsessions] FAILED to hook WriteFormattedLogMessage!\n");
} else {
con.print("[logsessions] hooked WriteFormattedLogMessage OK\n");
}
}
pub fn removeHooks() void {
if (g_is_hook_owner) {
write_log_hook.detach();
init_log_hook.detach();
enter_world_hook.detach();
restorePathPointers();
if (g_mutex) |m| {
_ = ReleaseMutex(m);
_ = CloseHandle(m);
g_mutex = null;
}
}
g_is_hook_owner = false;
}
+69
View File
@@ -0,0 +1,69 @@
//! Address constants for log session rotation.
// =============================================================================
// Combat log path and state
// =============================================================================
/// Path table pointer — .data section (RW), points to "Logs\WoWCombatLog.txt" string.
/// Index 0 (0x0084360c) = chat log, index 1 (0x00843610) = combat log.
/// Overwriting the u32 at this address redirects where the combat log file is created.
pub const COMBAT_LOG_PATH_PTR: usize = 0x00843610;
/// Chat log path pointer — .data section (RW), points to "Logs\WoWChatLog.txt" string.
/// Index 0 in the path table at 0x0084360c.
pub const CHAT_LOG_PATH_PTR: usize = 0x0084360c;
/// Runtime combat log buffer handle (log_handles[1], BSS).
/// Non-zero when combat logging is active. Read to check if we can write log lines.
pub const COMBAT_LOG_HANDLE: usize = 0x00b50544;
/// Runtime chat log buffer handle (log_handles[0], BSS).
pub const CHAT_LOG_HANDLE: usize = 0x00b50540;
// =============================================================================
// Realm name
// =============================================================================
/// CVar base pointer for realm name. Dereference once, then read string at +0x20.
pub const REALM_NAME_CVAR_BASE: usize = 0x00c28130;
// =============================================================================
// Log writing
// =============================================================================
/// InitializeLogBuffer — __stdcall(filePath: [*:0]const u8, flags: u32, handleOut: *u32).
/// Creates a log buffer context. Copies path into context struct (max 260 bytes).
/// Returns nonzero on success. Callee cleans stack (RET 0xC).
/// Called by EnableChatLogging (game) and CombatLogAdd (SuperWoW) with hardcoded paths.
pub const FN_INIT_LOG_BUFFER: usize = 0x0065a0c0;
/// 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;
// =============================================================================
// Character select / enter world
// =============================================================================
/// HandleCharacterSelection — void(void), called by Lua EnterWorld().
/// Fires when the player clicks "Enter World" on the character select screen,
/// before world loading and log buffer initialization.
pub const FN_HANDLE_CHAR_SELECT: usize = 0x0046b500;
/// Selected character index (int) on the character select screen.
pub const CHAR_SELECT_INDEX: usize = 0x0083856c;
/// Character list base pointer. Each entry is 0x120 bytes.
/// Character name (C string) is at entry + 0x08.
pub const CHAR_LIST_BASE: usize = 0x00b42144;
/// Number of characters in the character list.
pub const CHAR_LIST_COUNT: usize = 0x00b42140;
/// Size of each character entry in the character list.
pub const CHAR_ENTRY_SIZE: usize = 0x120;
/// Offset of character name within a character entry.
pub const CHAR_NAME_OFFSET: usize = 0x08;