543 lines
18 KiB
C
543 lines
18 KiB
C
/*
|
|
* WowPresence.exe
|
|
* Invisible companion process for WowPresence.
|
|
*
|
|
* - is started automatically by WowPresence.dll after WoW is running
|
|
* - reads the standalone WowPresence folder or the Modernization Tool managed folder
|
|
* - publishes Discord Rich Presence over the local Discord IPC named pipe
|
|
* - exits when the target WoW process exits
|
|
*/
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#include <windows.h>
|
|
#include <tlhelp32.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
|
|
#define STATUS_MAX_AGE 30
|
|
#define TICK_MS 2000
|
|
#define PIPE_COUNT 10
|
|
#define IPC_OPCODE_HANDSHAKE 0u
|
|
#define IPC_OPCODE_FRAME 1u
|
|
|
|
typedef struct {
|
|
char name[32];
|
|
char zone[80];
|
|
char guild[64];
|
|
char faction[24];
|
|
char class_name[32];
|
|
char race[32];
|
|
int level;
|
|
long long ts;
|
|
int ok;
|
|
int in_world;
|
|
} Status;
|
|
|
|
typedef struct {
|
|
HANDLE pipe;
|
|
char last_activity[768];
|
|
unsigned long nonce;
|
|
} DiscordRpc;
|
|
|
|
static int own_directory(char *out, size_t out_size) {
|
|
char path[MAX_PATH];
|
|
char *slash;
|
|
if (!GetModuleFileNameA(NULL, path, MAX_PATH)) return 0;
|
|
slash = strrchr(path, '\\');
|
|
if (!slash) slash = strrchr(path, '/');
|
|
if (!slash) return 0;
|
|
*slash = 0;
|
|
if (strlen(path) + 1 > out_size) return 0;
|
|
lstrcpynA(out, path, (int)out_size);
|
|
return 1;
|
|
}
|
|
|
|
static int directory_exists(const char *path) {
|
|
DWORD attrs;
|
|
if (!path || !path[0]) return 0;
|
|
attrs = GetFileAttributesA(path);
|
|
return attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_DIRECTORY);
|
|
}
|
|
|
|
static int support_directory(char *out, size_t out_size) {
|
|
char root[MAX_PATH], managed[MAX_PATH];
|
|
if (!own_directory(root, sizeof(root))) return 0;
|
|
|
|
_snprintf(
|
|
managed,
|
|
sizeof(managed),
|
|
"%s\\.modernization_tool\\WowPresence",
|
|
root
|
|
);
|
|
managed[sizeof(managed) - 1] = 0;
|
|
if (directory_exists(managed)) {
|
|
if (strlen(managed) + 1 >= out_size) return 0;
|
|
lstrcpynA(out, managed, (int)out_size);
|
|
return 1;
|
|
}
|
|
|
|
if (strlen(root) + strlen("\\WowPresence") + 1 >= out_size) return 0;
|
|
_snprintf(out, out_size, "%s\\WowPresence", root);
|
|
out[out_size - 1] = 0;
|
|
CreateDirectoryA(out, NULL);
|
|
return 1;
|
|
}
|
|
|
|
static int support_file(char *out, size_t out_size, const char *name) {
|
|
char dir[MAX_PATH];
|
|
if (!support_directory(dir, sizeof(dir))) return 0;
|
|
if (strlen(dir) + 1 + strlen(name) + 1 > out_size) return 0;
|
|
_snprintf(out, out_size, "%s\\%s", dir, name);
|
|
out[out_size - 1] = 0;
|
|
return 1;
|
|
}
|
|
|
|
static void log_line(const char *text) {
|
|
char path[MAX_PATH];
|
|
HANDLE file;
|
|
DWORD written;
|
|
SYSTEMTIME st;
|
|
char line[1200];
|
|
if (!support_file(path, sizeof(path), "WowPresence.log")) return;
|
|
file = CreateFileA(path, FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE,
|
|
NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
|
|
if (file == INVALID_HANDLE_VALUE) return;
|
|
GetLocalTime(&st);
|
|
_snprintf(line, sizeof(line), "%04u-%02u-%02u %02u:%02u:%02u %s\r\n",
|
|
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond,
|
|
text ? text : "");
|
|
line[sizeof(line) - 1] = 0;
|
|
WriteFile(file, line, (DWORD)strlen(line), &written, NULL);
|
|
CloseHandle(file);
|
|
}
|
|
|
|
static int read_text_file(const char *path, char *out, size_t out_size) {
|
|
HANDLE file;
|
|
DWORD got = 0;
|
|
if (!out || out_size < 2) return 0;
|
|
out[0] = 0;
|
|
file = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
|
NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
|
|
if (file == INVALID_HANDLE_VALUE) return 0;
|
|
if (!ReadFile(file, out, (DWORD)(out_size - 1), &got, NULL)) {
|
|
CloseHandle(file);
|
|
return 0;
|
|
}
|
|
CloseHandle(file);
|
|
out[got] = 0;
|
|
return 1;
|
|
}
|
|
|
|
static void trim_ascii(char *text) {
|
|
char *start, *end;
|
|
if (!text) return;
|
|
start = text;
|
|
while (*start == ' ' || *start == '\t' || *start == '\r' || *start == '\n') ++start;
|
|
if (start != text) memmove(text, start, strlen(start) + 1);
|
|
end = text + strlen(text);
|
|
while (end > text && (end[-1] == ' ' || end[-1] == '\t' || end[-1] == '\r' || end[-1] == '\n'))
|
|
--end;
|
|
*end = 0;
|
|
}
|
|
|
|
static int valid_application_id(const char *text) {
|
|
size_t i, n;
|
|
if (!text) return 0;
|
|
n = strlen(text);
|
|
if (n < 15 || n > 24) return 0;
|
|
for (i = 0; i < n; ++i)
|
|
if (text[i] < '0' || text[i] > '9') return 0;
|
|
return 1;
|
|
}
|
|
|
|
static int load_application_id(char *out, size_t out_size) {
|
|
char path[MAX_PATH];
|
|
char configured_id[64] = {0};
|
|
|
|
if (!out || out_size < 25u) return 0;
|
|
out[0] = 0;
|
|
|
|
if (!support_file(path, sizeof(path), "discord_application_id") ||
|
|
!read_text_file(path, configured_id, sizeof(configured_id))) {
|
|
log_line("discord_application_id is missing. Add your Discord Application ID to the active WowPresence data folder.");
|
|
return 0;
|
|
}
|
|
|
|
trim_ascii(configured_id);
|
|
if (!valid_application_id(configured_id)) {
|
|
log_line("discord_application_id is invalid. Replace the file contents with your numeric Discord Application ID.");
|
|
return 0;
|
|
}
|
|
|
|
lstrcpynA(out, configured_id, (int)out_size);
|
|
return 1;
|
|
}
|
|
|
|
static DWORD parse_target_pid(const char *cmdline) {
|
|
const char *p;
|
|
char *end = NULL;
|
|
unsigned long value;
|
|
|
|
if (!cmdline) return 0;
|
|
p = strstr(cmdline, "--pid");
|
|
if (!p) return 0;
|
|
p += 5;
|
|
while (*p == ' ' || *p == '\t') ++p;
|
|
if (!*p) return 0;
|
|
|
|
value = strtoul(p, &end, 10);
|
|
if (end == p || value == 0 || value > 0xFFFFFFFFul) return 0;
|
|
return (DWORD)value;
|
|
}
|
|
|
|
static DWORD find_process(const char *exe_name) {
|
|
HANDLE snapshot;
|
|
PROCESSENTRY32 entry;
|
|
DWORD pid = 0;
|
|
snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
|
if (snapshot == INVALID_HANDLE_VALUE) return 0;
|
|
memset(&entry, 0, sizeof(entry));
|
|
entry.dwSize = sizeof(entry);
|
|
if (Process32First(snapshot, &entry)) {
|
|
do {
|
|
if (_stricmp(entry.szExeFile, exe_name) == 0) {
|
|
pid = entry.th32ProcessID;
|
|
break;
|
|
}
|
|
} while (Process32Next(snapshot, &entry));
|
|
}
|
|
CloseHandle(snapshot);
|
|
return pid;
|
|
}
|
|
|
|
static const char *find_key(const char *json, const char *key) {
|
|
static char pattern[80];
|
|
const char *p;
|
|
_snprintf(pattern, sizeof(pattern), "\"%s\":", key);
|
|
pattern[sizeof(pattern) - 1] = 0;
|
|
p = strstr(json, pattern);
|
|
return p ? p + strlen(pattern) : NULL;
|
|
}
|
|
|
|
static int json_bool(const char *json, const char *key, int *value) {
|
|
const char *p = find_key(json, key);
|
|
if (!p || !value) return 0;
|
|
while (*p == ' ' || *p == '\t') ++p;
|
|
if (strncmp(p, "true", 4) == 0) { *value = 1; return 1; }
|
|
if (strncmp(p, "false", 5) == 0) { *value = 0; return 1; }
|
|
return 0;
|
|
}
|
|
|
|
static int json_int64(const char *json, const char *key, long long *value) {
|
|
const char *p = find_key(json, key);
|
|
char *end = NULL;
|
|
long long v;
|
|
if (!p || !value) return 0;
|
|
v = _strtoi64(p, &end, 10);
|
|
if (end == p) return 0;
|
|
*value = v;
|
|
return 1;
|
|
}
|
|
|
|
static int json_int(const char *json, const char *key, int *value) {
|
|
long long v = 0;
|
|
if (!json_int64(json, key, &v)) return 0;
|
|
*value = (int)v;
|
|
return 1;
|
|
}
|
|
|
|
static int json_string(const char *json, const char *key, char *out, size_t out_size) {
|
|
const char *p = find_key(json, key);
|
|
size_t w = 0;
|
|
if (!p || !out || out_size < 2) return 0;
|
|
while (*p == ' ' || *p == '\t') ++p;
|
|
if (*p++ != '"') return 0;
|
|
while (*p && *p != '"' && w + 1 < out_size) {
|
|
if (*p == '\\') {
|
|
++p;
|
|
if (!*p) break;
|
|
if (*p == '"' || *p == '\\' || *p == '/') out[w++] = *p++;
|
|
else if (*p == 'n') { out[w++] = '\n'; ++p; }
|
|
else if (*p == 'r') { out[w++] = '\r'; ++p; }
|
|
else if (*p == 't') { out[w++] = '\t'; ++p; }
|
|
else return 0;
|
|
} else {
|
|
out[w++] = *p++;
|
|
}
|
|
}
|
|
out[w] = 0;
|
|
return *p == '"';
|
|
}
|
|
|
|
static int load_status(Status *status) {
|
|
char path[MAX_PATH], json[2048];
|
|
time_t now;
|
|
memset(status, 0, sizeof(*status));
|
|
if (!support_file(path, sizeof(path), "discord_wow_status.json")) return 0;
|
|
if (!read_text_file(path, json, sizeof(json))) return 0;
|
|
if (!json_bool(json, "ok", &status->ok) ||
|
|
!json_bool(json, "in_world", &status->in_world) ||
|
|
!json_int64(json, "ts", &status->ts))
|
|
return 0;
|
|
now = time(NULL);
|
|
if (!status->ok || !status->in_world ||
|
|
status->ts <= 0 || (long long)now - status->ts > STATUS_MAX_AGE ||
|
|
status->ts - (long long)now > 5)
|
|
return 0;
|
|
json_string(json, "name", status->name, sizeof(status->name));
|
|
json_string(json, "zone", status->zone, sizeof(status->zone));
|
|
json_string(json, "guild", status->guild, sizeof(status->guild));
|
|
json_string(json, "faction", status->faction, sizeof(status->faction));
|
|
json_string(json, "class", status->class_name, sizeof(status->class_name));
|
|
json_string(json, "race", status->race, sizeof(status->race));
|
|
json_int(json, "level", &status->level);
|
|
return status->name[0] || status->zone[0] || status->level > 0 ||
|
|
status->guild[0] || status->faction[0] || status->class_name[0] ||
|
|
status->race[0];
|
|
}
|
|
|
|
static int rpc_read_packet(HANDLE pipe, char *payload, size_t payload_size) {
|
|
uint32_t header[2];
|
|
DWORD got = 0, total = 0;
|
|
if (!ReadFile(pipe, header, sizeof(header), &got, NULL) || got != sizeof(header)) return 0;
|
|
if (header[1] >= payload_size) return 0;
|
|
while (total < header[1]) {
|
|
DWORD chunk = 0;
|
|
if (!ReadFile(pipe, payload + total, header[1] - total, &chunk, NULL) || chunk == 0)
|
|
return 0;
|
|
total += chunk;
|
|
}
|
|
payload[total] = 0;
|
|
return 1;
|
|
}
|
|
|
|
static int rpc_write_packet(HANDLE pipe, uint32_t opcode, const char *json) {
|
|
unsigned char buffer[2048];
|
|
uint32_t *header = (uint32_t *)buffer;
|
|
DWORD written = 0;
|
|
size_t len = strlen(json);
|
|
if (len + 8 > sizeof(buffer)) return 0;
|
|
header[0] = opcode;
|
|
header[1] = (uint32_t)len;
|
|
memcpy(buffer + 8, json, len);
|
|
return WriteFile(pipe, buffer, (DWORD)(len + 8), &written, NULL) &&
|
|
written == (DWORD)(len + 8);
|
|
}
|
|
|
|
static void rpc_close(DiscordRpc *rpc) {
|
|
if (rpc->pipe && rpc->pipe != INVALID_HANDLE_VALUE) CloseHandle(rpc->pipe);
|
|
rpc->pipe = INVALID_HANDLE_VALUE;
|
|
rpc->last_activity[0] = 0;
|
|
}
|
|
|
|
static int rpc_connect(DiscordRpc *rpc, const char *application_id) {
|
|
int i;
|
|
char pipe_name[64], handshake[160], response[2048];
|
|
rpc_close(rpc);
|
|
for (i = 0; i < PIPE_COUNT; ++i) {
|
|
_snprintf(pipe_name, sizeof(pipe_name), "\\\\.\\pipe\\discord-ipc-%d", i);
|
|
rpc->pipe = CreateFileA(pipe_name, GENERIC_READ | GENERIC_WRITE, 0, NULL,
|
|
OPEN_EXISTING, 0, NULL);
|
|
if (rpc->pipe != INVALID_HANDLE_VALUE) break;
|
|
}
|
|
if (rpc->pipe == INVALID_HANDLE_VALUE) return 0;
|
|
_snprintf(handshake, sizeof(handshake), "{\"v\":1,\"client_id\":\"%s\"}", application_id);
|
|
if (!rpc_write_packet(rpc->pipe, IPC_OPCODE_HANDSHAKE, handshake) ||
|
|
!rpc_read_packet(rpc->pipe, response, sizeof(response)) ||
|
|
(!strstr(response, "\"READY\"") && !strstr(response, "\"evt\":\"READY\""))) {
|
|
rpc_close(rpc);
|
|
return 0;
|
|
}
|
|
log_line("Connected to Discord IPC.");
|
|
return 1;
|
|
}
|
|
|
|
static void title_case_faction(char *text) {
|
|
if (text && text[0] >= 'a' && text[0] <= 'z') text[0] = (char)(text[0] - 'a' + 'A');
|
|
}
|
|
|
|
static void format_activity(const Status *s, char *out, size_t out_size, long long session_start) {
|
|
char details[384] = {0};
|
|
char extra[192] = {0};
|
|
char faction[24] = {0};
|
|
if (s->faction[0]) {
|
|
lstrcpynA(faction, s->faction, sizeof(faction));
|
|
title_case_faction(faction);
|
|
}
|
|
|
|
if (s->name[0] && s->guild[0])
|
|
_snprintf(details, sizeof(details), "%s <%s>", s->name, s->guild);
|
|
else if (s->name[0])
|
|
_snprintf(details, sizeof(details), "%s", s->name);
|
|
else if (s->guild[0])
|
|
_snprintf(details, sizeof(details), "<%s>", s->guild);
|
|
|
|
if (s->level > 0 && s->class_name[0])
|
|
_snprintf(extra, sizeof(extra), "Lvl %d %s", s->level, s->class_name);
|
|
else if (s->level > 0)
|
|
_snprintf(extra, sizeof(extra), "Lvl %d", s->level);
|
|
else if (s->class_name[0])
|
|
_snprintf(extra, sizeof(extra), "%s", s->class_name);
|
|
|
|
if (s->race[0]) {
|
|
size_t used = strlen(extra);
|
|
if (used)
|
|
_snprintf(extra + used, sizeof(extra) - used, " \\u00b7 %s", s->race);
|
|
else
|
|
_snprintf(extra, sizeof(extra), "%s", s->race);
|
|
}
|
|
|
|
if (faction[0]) {
|
|
size_t used = strlen(extra);
|
|
if (used)
|
|
_snprintf(extra + used, sizeof(extra) - used, " \\u00b7 %s", faction);
|
|
else
|
|
_snprintf(extra, sizeof(extra), "%s", faction);
|
|
}
|
|
|
|
if (details[0] && extra[0]) {
|
|
size_t used = strlen(details);
|
|
_snprintf(details + used, sizeof(details) - used, " \\u00b7 %s", extra);
|
|
} else if (!details[0] && extra[0]) {
|
|
lstrcpynA(details, extra, sizeof(details));
|
|
}
|
|
|
|
/* Discord rejects empty string values for Rich Presence text fields.
|
|
* Omit a field entirely when the user did not choose anything that maps
|
|
* to it, so Race/Class/Faction/Guild-only and Zone-only combinations work. */
|
|
if (details[0] && s->zone[0]) {
|
|
_snprintf(out, out_size,
|
|
"{\"details\":\"%s\",\"state\":\"%s\",\"timestamps\":{\"start\":%lld}}",
|
|
details, s->zone, session_start);
|
|
} else if (details[0]) {
|
|
_snprintf(out, out_size,
|
|
"{\"details\":\"%s\",\"timestamps\":{\"start\":%lld}}",
|
|
details, session_start);
|
|
} else if (s->zone[0]) {
|
|
_snprintf(out, out_size,
|
|
"{\"state\":\"%s\",\"timestamps\":{\"start\":%lld}}",
|
|
s->zone, session_start);
|
|
} else {
|
|
_snprintf(out, out_size,
|
|
"{\"timestamps\":{\"start\":%lld}}",
|
|
session_start);
|
|
}
|
|
out[out_size - 1] = 0;
|
|
}
|
|
|
|
static int rpc_set_activity(DiscordRpc *rpc, DWORD pid, const char *activity_json) {
|
|
char command[1400], response[2048];
|
|
if (!rpc || rpc->pipe == INVALID_HANDLE_VALUE) return 0;
|
|
if (strcmp(rpc->last_activity, activity_json) == 0) return 1;
|
|
++rpc->nonce;
|
|
_snprintf(command, sizeof(command),
|
|
"{\"cmd\":\"SET_ACTIVITY\",\"args\":{\"pid\":%lu,\"activity\":%s},"
|
|
"\"nonce\":\"%lu\"}",
|
|
(unsigned long)pid, activity_json, rpc->nonce);
|
|
command[sizeof(command) - 1] = 0;
|
|
if (!rpc_write_packet(rpc->pipe, IPC_OPCODE_FRAME, command) ||
|
|
!rpc_read_packet(rpc->pipe, response, sizeof(response))) {
|
|
rpc_close(rpc);
|
|
return 0;
|
|
}
|
|
if (strstr(response, "\"ERROR\"")) {
|
|
log_line(response);
|
|
return 0;
|
|
}
|
|
lstrcpynA(rpc->last_activity, activity_json, sizeof(rpc->last_activity));
|
|
return 1;
|
|
}
|
|
|
|
static void rpc_clear(DiscordRpc *rpc, DWORD pid) {
|
|
char command[256], response[1024];
|
|
if (!rpc || rpc->pipe == INVALID_HANDLE_VALUE) return;
|
|
++rpc->nonce;
|
|
_snprintf(command, sizeof(command),
|
|
"{\"cmd\":\"SET_ACTIVITY\",\"args\":{\"pid\":%lu,\"activity\":null},"
|
|
"\"nonce\":\"%lu\"}",
|
|
(unsigned long)pid, rpc->nonce);
|
|
rpc_write_packet(rpc->pipe, IPC_OPCODE_FRAME, command);
|
|
rpc_read_packet(rpc->pipe, response, sizeof(response));
|
|
}
|
|
|
|
int WINAPI WinMain(HINSTANCE instance, HINSTANCE prev, LPSTR cmdline, int show) {
|
|
char application_id[64] = {0};
|
|
DiscordRpc rpc;
|
|
HANDLE wow_process = NULL;
|
|
DWORD pid = 0;
|
|
DWORD requested_pid = 0;
|
|
int have_id;
|
|
int wait_loops = 0;
|
|
long long session_start = 0;
|
|
(void)instance; (void)prev; (void)show;
|
|
|
|
memset(&rpc, 0, sizeof(rpc));
|
|
rpc.pipe = INVALID_HANDLE_VALUE;
|
|
rpc.nonce = (unsigned long)GetTickCount();
|
|
|
|
have_id = load_application_id(application_id, sizeof(application_id));
|
|
if (!have_id) {
|
|
log_line("Discord Rich Presence is disabled until a valid discord_application_id is configured.");
|
|
}
|
|
|
|
/* Normal path: WowPresence.dll passes the exact WoW process id.
|
|
* The name scan remains only as a harmless manual/debug fallback. */
|
|
requested_pid = parse_target_pid(cmdline);
|
|
pid = requested_pid;
|
|
|
|
while (!pid && wait_loops++ < 25) {
|
|
pid = find_process("WoW_Modernized.exe");
|
|
if (!pid) pid = find_process("WoW.exe");
|
|
if (!pid) Sleep(200);
|
|
}
|
|
if (!pid) {
|
|
log_line("No running WoW process found; WowPresence exiting.");
|
|
return 0;
|
|
}
|
|
|
|
wow_process = OpenProcess(SYNCHRONIZE, FALSE, pid);
|
|
if (!wow_process) {
|
|
log_line("Could not open the target WoW process; WowPresence exiting.");
|
|
return 0;
|
|
}
|
|
|
|
log_line("WoW process detected.");
|
|
/* Fixed for the lifetime of this WoW process. Text updates, character
|
|
* changes and loading states reuse the same Discord elapsed-time origin. */
|
|
session_start = (long long)time(NULL);
|
|
|
|
while (WaitForSingleObject(wow_process, 0) == WAIT_TIMEOUT) {
|
|
Status status;
|
|
char activity[768];
|
|
|
|
if (have_id && rpc.pipe == INVALID_HANDLE_VALUE)
|
|
rpc_connect(&rpc, application_id);
|
|
|
|
if (have_id && rpc.pipe != INVALID_HANDLE_VALUE) {
|
|
if (load_status(&status)) {
|
|
format_activity(&status, activity, sizeof(activity), session_start);
|
|
rpc_set_activity(&rpc, pid, activity);
|
|
} else {
|
|
/* Keep only the Discord application header while the game is
|
|
* loading, on character select, or when the snapshot is stale.
|
|
* This also prevents old character/zone text from lingering. */
|
|
_snprintf(activity, sizeof(activity),
|
|
"{\"timestamps\":{\"start\":%lld}}", session_start);
|
|
activity[sizeof(activity) - 1] = 0;
|
|
rpc_set_activity(&rpc, pid, activity);
|
|
}
|
|
}
|
|
Sleep(TICK_MS);
|
|
}
|
|
|
|
if (rpc.pipe != INVALID_HANDLE_VALUE) {
|
|
rpc_clear(&rpc, pid);
|
|
rpc_close(&rpc);
|
|
}
|
|
CloseHandle(wow_process);
|
|
log_line("WoW stopped; WowPresence exiting.");
|
|
return 0;
|
|
}
|