Files
WeirdUtils/src/combatlog/combatlog.zig
T
MarcelineVQ ddd4c9681d Add session markers for chat and raw combat logs
Write CHAT_SESSION and COMBATLOG_SESSION markers on first write to each
log type. Raw combat log handle address captured dynamically from
SuperWoW's InitializeLogBuffer call.
2026-03-04 14:01:29 -08:00

540 lines
21 KiB
Zig

//! Combat 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 < 30 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 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("[combatlog] 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 30 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 = 30 * 60 * 10_000_000; // 30 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 < 30 min ago)
if (findRecentFile(prefix, result_buf)) |len| {
con.fmt("[combatlog] reusing: {s}\n", .{result_buf[0..len]});
return len;
}
// Generate new timestamped filename
var st: SYSTEMTIME = undefined;
GetLocalTime(&st);
const pid = GetCurrentProcessId();
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}_{d}.txt", .{
g_dir_path[0..g_dir_path_len],
prefix,
st.wYear,
st.wMonth,
st.wDay,
st.wHour,
st.wMinute,
st.wSecond,
pid,
}) catch return 0;
result_buf[path.len] = 0;
con.fmt("[combatlog] 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("[combatlog] 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("[combatlog] 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("[combatlog] 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("[combatlog] 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("[combatlog] 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("[combatlog] 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("[combatlog] 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("[combatlog] 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();
}
// =============================================================================
// Install / remove hooks
// =============================================================================
pub fn installHooks() void {
con.print("[combatlog] 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_CombatlogHook_{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("[combatlog] 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("[combatlog] FAILED to hook HandleCharacterSelection!\n");
} else {
con.print("[combatlog] hooked HandleCharacterSelection OK\n");
}
if (init_log_hook.attach(o.FN_INIT_LOG_BUFFER, &initLogDetour) != .ok) {
con.print("[combatlog] FAILED to hook InitializeLogBuffer!\n");
} else {
con.print("[combatlog] hooked InitializeLogBuffer OK\n");
}
if (write_log_hook.attach(o.FN_WRITE_FMT_LOG_MSG, &writeLogDetour) != .ok) {
con.print("[combatlog] FAILED to hook WriteFormattedLogMessage!\n");
} else {
con.print("[combatlog] 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;
}