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:
@@ -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.
|
||||
@@ -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(¤t_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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user