From 36391f1ea9b26c751505e3313710d5b672359800 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 15:41:42 +0200 Subject: [PATCH 01/35] Set up standalone WowPresence project --- .github/workflows/build.yml | 77 ++++ README.md | 156 +++++++- THIRD_PARTY_NOTICES.md | 35 ++ src/WowPresence.c | 765 ++++++++++++++++++++++++++++++++++++ src/WowPresenceLauncher.c | 504 ++++++++++++++++++++++++ 5 files changed, 1536 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build.yml create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 src/WowPresence.c create mode 100644 src/WowPresenceLauncher.c diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b577716 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,77 @@ +name: Build WowPresence + +on: + push: + branches: + - main + tags: + - 'v*' + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: wowpresence-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: windows-2025 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Configure x86 MSVC + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x86 + + - name: Build native components + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "dist" | Out-Null + cl /nologo /O2 /MT /W3 /LD "src\WowPresence.c" /Fe:"dist\WowPresence.dll" /link /INCREMENTAL:NO + if ($LASTEXITCODE -ne 0) { throw "WowPresence.dll build failed" } + + cl /nologo /O2 /MT /W3 "src\WowPresenceLauncher.c" user32.lib /Fe:"dist\WowPresence.exe" /link /SUBSYSTEM:WINDOWS /INCREMENTAL:NO + if ($LASTEXITCODE -ne 0) { throw "WowPresence.exe build failed" } + + if (-not (Test-Path "dist\WowPresence.dll")) { throw "WowPresence.dll missing" } + if (-not (Test-Path "dist\WowPresence.exe")) { throw "WowPresence.exe missing" } + + - name: Create ZIP package + shell: pwsh + run: | + $zip = "dist\WowPresence.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path "dist\WowPresence.dll","dist\WowPresence.exe" -DestinationPath $zip -CompressionLevel Optimal + if (-not (Test-Path $zip)) { throw "WowPresence.zip missing" } + + - name: Upload build artifact + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + uses: actions/upload-artifact@v4 + with: + name: WowPresence-build + path: | + dist/WowPresence.dll + dist/WowPresence.exe + dist/WowPresence.zip + if-no-files-found: error + + - name: Create GitHub Release + if: ${{ startsWith(github.ref, 'refs/tags/') }} + uses: softprops/action-gh-release@v3 + with: + files: | + dist/WowPresence.dll + dist/WowPresence.exe + dist/WowPresence.zip + fail_on_unmatched_files: true + name: "WowPresence ${{ github.ref_name }}" + generate_release_notes: true + make_latest: true diff --git a/README.md b/README.md index 083f807..25e62a5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,156 @@ # WowPresence -Native Discord Rich Presence for World of Warcraft 1.12.1 (build 5875) and compatible clients. + +Native Discord Rich Presence for **World of Warcraft 1.12.1 (build 5875)** and compatible clients. + +WowPresence keeps the game-side component deliberately small: a read-only DLL samples character status from the WoW process and writes a local JSON snapshot, while a separate companion executable handles Discord IPC outside the game process. + +## Features + +- Character name and guild +- Level and class +- Race and faction +- Current zone +- Stable elapsed-session timer across character changes +- Automatic companion startup +- Exact WoW process tracking +- Read-only game-memory access +- No Discord bot, Client Secret, or network service required + +## Compatibility + +### Tested + +- OctoWoW client based on WoW 1.12.1 / build 5875 + +### Expected compatibility + +- World of Warcraft 1.12.1 build 5875 +- Compatible private-server clients that preserve the same relevant client memory layout + +### Not compatible + +- WoW Classic Era 1.14.x +- The Burning Crusade Classic +- Wrath of the Lich King Classic +- Retail World of Warcraft + +Other modified 1.12.1 clients should be considered **untested until verified**. + +## Files + +```text +WowPresence.dll +WowPresence.exe +``` + +The DLL is loaded into WoW by a compatible DLL loader such as VanillaFixes. After WoW is running, the DLL automatically starts `WowPresence.exe`. + +The companion executable should normally **not** be launched manually. + +## Installation + +1. Download `WowPresence.dll` and `WowPresence.exe` from Releases. +2. Place both files in the WoW game directory. +3. Add this line to the VanillaFixes `dlls.txt` file: + +```text +WowPresence.dll +``` + +4. Launch WoW normally through VanillaFixes. + +WowPresence creates its runtime files under: + +```text +\.modernization_tool\WowPresence\ +``` + +## Discord application + +The current build includes the OctoWoW Discord Application ID as its default so it works immediately with the configuration it was originally developed and tested against. + +Advanced users can override it by creating: + +```text +.modernization_tool\WowPresence\discord_application_id +``` + +The file should contain only the numeric Discord Application ID. + +No Client Secret is used or required. + +## Privacy flags + +The optional file: + +```text +.modernization_tool\WowPresence\discord_broadcast_flags +``` + +controls which character details are published to Discord. + +| Value | Field | +| ---: | --- | +| 1 | Name | +| 2 | Guild | +| 4 | Faction | +| 8 | Class | +| 16 | Level | +| 32 | Zone | +| 63 | All fields | + +If the file is missing, all supported fields are enabled. + +## Administrator privileges + +Discord and WoW should run with matching privilege levels. + +If Discord is running as administrator, WoW must also run as administrator for local Discord Rich Presence IPC to work reliably. + +Running both normally is recommended unless elevation is actually required by the local setup. + +## Runtime design + +```text +WoW + └─ WowPresence.dll + ├─ read-only character sampling + ├─ local JSON status + └─ starts WowPresence.exe + └─ Discord local IPC +``` + +The DLL does not communicate directly with Discord. The companion executable does not read or write WoW memory. + +Status data is written to: + +```text +.modernization_tool\WowPresence\discord_wow_status.json +``` + +The companion log is written to: + +```text +.modernization_tool\WowPresence\WowPresence.log +``` + +## Building + +The native components target **32-bit x86 Windows**, matching WoW 1.12.1. + +With an x86 MSVC developer environment: + +```bat +cl /nologo /O2 /MT /W3 /LD src\WowPresence.c /Fe:WowPresence.dll /link /INCREMENTAL:NO +cl /nologo /O2 /MT /W3 src\WowPresenceLauncher.c user32.lib /Fe:WowPresence.exe /link /SUBSYSTEM:WINDOWS /INCREMENTAL:NO +``` + +GitHub Actions also builds both binaries automatically. Tagged versions matching `v*` publish a GitHub Release containing the DLL, EXE, and ZIP package. + +## Credits and provenance + +WowPresence is maintained by **Dusk-92**. + +The project was inspired in part by the WoW character-status / Discord Rich Presence work in **IchaLaunch** by **brutaliccus**. The current implementation is maintained separately and does not include IchaLaunch binaries. + +See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for additional provenance and trademark information. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..fcdac5a --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,35 @@ +# Third-Party Notices + +WowPresence contains original project code maintained by Dusk-92 and uses compatibility knowledge related to World of Warcraft 1.12.1 and Discord local Rich Presence IPC. + +## IchaLaunch + +The development of WowPresence was inspired in part by the character-status / Discord Rich Presence implementation in: + +- Project: IchaLaunch +- Author / maintainer: brutaliccus +- Repository: https://github.com/brutaliccus/IchaLaunch + +IchaLaunch was used as a technical reference during development, particularly for understanding a working WoW 1.12.1 character-status sampling approach. + +The current WowPresence implementation is maintained separately and has been independently reworked. No IchaLaunch binary is included in this repository. + +At the historical IchaLaunch revision reviewed during development, no explicit LICENSE, LICENSE.md, or COPYING file was present. This notice therefore records provenance and credit; it should not be interpreted as granting rights on behalf of the IchaLaunch author. + +## World of Warcraft client compatibility data + +WowPresence uses client-layout information and memory offsets associated with World of Warcraft 1.12.1 build 5875. Such compatibility information has been documented in multiple community projects over the years. + +These values and client structures are used solely to locate read-only character status information in a compatible running client. + +## Discord + +WowPresence communicates with the locally running Discord desktop client through Discord's local IPC / Rich Presence protocol. + +Discord is a trademark of Discord Inc. WowPresence is not affiliated with or endorsed by Discord Inc. + +## Blizzard Entertainment / World of Warcraft + +World of Warcraft, Warcraft, Blizzard, and related names and marks are trademarks or registered trademarks of Blizzard Entertainment, Inc. + +WowPresence is an independent community project and is not affiliated with or endorsed by Blizzard Entertainment. diff --git a/src/WowPresence.c b/src/WowPresence.c new file mode 100644 index 0000000..66ecf45 --- /dev/null +++ b/src/WowPresence.c @@ -0,0 +1,765 @@ +/* + * WowPresence.dll + * Read-only WoW 1.12.1 (build 5875) status sampler for Modernization Tool. + * + * Runtime contract: + * \.modernization_tool\DiscordPresence\discord_wow_status.json + * \.modernization_tool\DiscordPresence\discord_broadcast_flags + * + * Discord IPC is intentionally out of process. After the first sample, this + * DLL starts WowPresence.exe from its worker thread and passes the exact + * WoW process id. No process launch is performed from DllMain. + * + * This implementation is organized independently around a small memory-view + * layer and a snapshot serializer. Client addresses are the community-known + * WoW 1.12.1 / build 5875 values used for compatibility with the target client. + */ +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include + +#define CLIENT_IMAGE_BASE 0x00400000u +#define CLIENT_OBJECT_MANAGER_VA 0x00B41414u +#define OM_FIRST_OBJECT_OFF 0xACu +#define OM_LOCAL_GUID_OFF 0xC0u +#define OBJECT_NEXT_OFF 0x3Cu +#define OBJECT_TYPE_OFF 0x14u +#define OBJECT_GUID_OFF 0x30u +#define OBJECT_DESCRIPTORS_OFF 0x08u +#define UNIT_LEVEL_OFF 0x88u +#define UNIT_BYTES0_OFF 0x90u +#define PLAYER_GUILD_ID_OFF 0x2FCu +#define PLAYER_INFO_OFF 0xE68u +#define PLAYER_INFO_GUILD_KEY_OFF 0x0Cu + +#define GUILD_CACHE_BASE_VA 0x00C0E0C0u +#define GUILD_CACHE_STRIDE 0x3Cu +#define GUILD_CACHE_COUNT 12u +#define GUILD_TAG_WGLD 0x444C4757u +#define GUILD_TAG_DLGW 0x57474C44u + +#define USER_ADDRESS_MIN 0x00010000u +#define USER_ADDRESS_MAX 0x7FFEFFFFu +#define PLAYER_OBJECT_TYPE 4u +#define MAX_OBJECT_VISITS 256u +#define MAX_GUILD_VISITS 32u + +#define STARTUP_WAIT_MS 6000u +#define SAMPLE_PERIOD_MS 2000u +#define WORLD_SETTLE_MS 3000u + +#define NAME_LIMIT 24u +#define ZONE_LIMIT 64u +#define GUILD_LIMIT 48u +#define JSON_BUFFER_SIZE 768u + +#define SHARE_NAME 1u +#define SHARE_GUILD 2u +#define SHARE_FACTION 4u +#define SHARE_CLASS 8u +#define SHARE_LEVEL 16u +#define SHARE_ZONE 32u +#define SHARE_ALL 63u + +static const uintptr_t kPlayerNameLocations[] = { + 0x00C27FC8u, 0x00C27D88u, 0x00C27FD8u, 0 +}; + +static const uintptr_t kZoneNameLocations[] = { + 0x00B4B404u, 0x00B4B424u, 0x00CE06D0u, + 0x00CE06F8u, 0x00B4B3C8u, 0 +}; + +typedef struct MemoryView { + HANDLE process; + uintptr_t module_base; +} MemoryView; + +typedef struct CharacterSnapshot { + int in_world; + char name[NAME_LIMIT + 1]; + char zone[ZONE_LIMIT + 1]; + char guild[GUILD_LIMIT + 1]; + uint32_t level; + uint32_t race_id; + uint32_t class_id; +} CharacterSnapshot; + +typedef struct LabelEntry { + uint32_t id; + const char *text; +} LabelEntry; + +static const LabelEntry kClassLabels[] = { + {1u, "Warrior"}, {2u, "Paladin"}, {3u, "Hunter"}, {4u, "Rogue"}, + {5u, "Priest"}, {7u, "Shaman"}, {8u, "Mage"}, {9u, "Warlock"}, + {11u, "Druid"}, {0u, ""} +}; + +static const LabelEntry kRaceLabels[] = { + {1u, "Human"}, {2u, "Orc"}, {3u, "Dwarf"}, {4u, "Night Elf"}, + {5u, "Undead"}, {6u, "Tauren"}, {7u, "Gnome"}, {8u, "Troll"}, + {9u, "Goblin"}, {10u, "High Elf"}, {11u, "Draenei"}, + {16u, "High Elf"}, {0u, ""} +}; + +static HANDLE gStopEvent = NULL; +static HANDLE gWorkerThread = NULL; +static DWORD gWorldReadySince = 0; + +static int user_address(uintptr_t value) { + return value >= (uintptr_t)USER_ADDRESS_MIN && value <= (uintptr_t)USER_ADDRESS_MAX; +} + +static int readable_protection(DWORD protection) { + DWORD base = protection & 0xFFu; + if (protection & (PAGE_GUARD | PAGE_NOACCESS)) return 0; + return base == PAGE_READONLY || base == PAGE_READWRITE || + base == PAGE_WRITECOPY || base == PAGE_EXECUTE_READ || + base == PAGE_EXECUTE_READWRITE || base == PAGE_EXECUTE_WRITECOPY; +} + +static int readable_range(uintptr_t start, size_t length) { + uintptr_t cursor, end; + if (!start || !length || length > 4096u) return 0; + end = start + length - 1u; + if (end < start || !user_address(start) || !user_address(end)) return 0; + + cursor = start; + while (cursor <= end) { + MEMORY_BASIC_INFORMATION info; + uintptr_t region_end; + if (!VirtualQuery((LPCVOID)cursor, &info, sizeof(info))) return 0; + if (info.State != MEM_COMMIT || !readable_protection(info.Protect)) return 0; + region_end = (uintptr_t)info.BaseAddress + info.RegionSize; + if (region_end <= cursor) return 0; + if (region_end > end) return 1; + cursor = region_end; + } + return 1; +} + +static void memory_view_init(MemoryView *view) { + if (!view) return; + view->process = GetCurrentProcess(); + view->module_base = (uintptr_t)GetModuleHandleA(NULL); +} + +static uintptr_t client_address(const MemoryView *view, uintptr_t vanilla_va) { + if (!view || !view->module_base || vanilla_va < CLIENT_IMAGE_BASE) return 0; + return view->module_base + (vanilla_va - CLIENT_IMAGE_BASE); +} + +static int memory_copy(const MemoryView *view, uintptr_t address, void *destination, size_t length) { + SIZE_T copied = 0; + if (!view || !view->process || !destination || !readable_range(address, length)) return 0; + if (!ReadProcessMemory(view->process, (LPCVOID)address, destination, length, &copied)) return 0; + return copied == length; +} + +static int memory_u32(const MemoryView *view, uintptr_t address, uint32_t *value) { + uint32_t temp = 0; + if (!value || !memory_copy(view, address, &temp, sizeof(temp))) return 0; + *value = temp; + return 1; +} + +static int memory_u64(const MemoryView *view, uintptr_t address, uint64_t *value) { + uint64_t temp = 0; + if (!value || !memory_copy(view, address, &temp, sizeof(temp))) return 0; + *value = temp; + return 1; +} + +static int memory_ascii(const MemoryView *view, uintptr_t address, char *output, + size_t output_size, size_t max_chars) { + size_t i; + if (!view || !output || output_size < 2u || !max_chars || !user_address(address)) return 0; + output[0] = 0; + if (max_chars + 1u > output_size) max_chars = output_size - 1u; + + for (i = 0; i <= max_chars; ++i) { + unsigned char ch = 0; + if (!memory_copy(view, address + i, &ch, 1u)) { + output[0] = 0; + return 0; + } + if (ch == 0) { + output[i] = 0; + return i > 0u; + } + if (i == max_chars || ch < 32u || ch >= 127u) { + output[0] = 0; + return 0; + } + output[i] = (char)ch; + } + output[0] = 0; + return 0; +} + +static int ascii_letter(char ch) { + return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); +} + +static int valid_player_name(const char *text) { + size_t i, length; + if (!text) return 0; + length = strlen(text); + if (length < 2u || length > 16u || !ascii_letter(text[0])) return 0; + for (i = 1u; i < length; ++i) { + if (!ascii_letter(text[i]) && text[i] != '\'') return 0; + } + return 1; +} + +static int valid_display_text(const char *text, size_t max_length) { + size_t i, length; + if (!text) return 0; + length = strlen(text); + if (length < 2u || length > max_length) return 0; + for (i = 0u; i < length; ++i) { + char ch = text[i]; + if (ascii_letter(ch) || (ch >= '0' && ch <= '9') || ch == ' ' || + ch == '\'' || ch == '-' || ch == ':') { + continue; + } + return 0; + } + return 1; +} + +static int looks_like_internal_zone_code(const char *text) { + size_t i, length; + int has_letter = 0; + int has_digit = 0; + + if (!text) return 0; + length = strlen(text); + + /* Some 1.12-compatible clients expose compact internal area tokens + * (for example "H32D") at one of the candidate zone addresses. These + * are identifiers, not user-facing zone names. Keep the rule narrow: + * short mixed letter/digit strings with no separators are rejected, + * while legitimate names such as "Area 52" remain valid. */ + if (length < 2u || length > 8u) return 0; + + for (i = 0u; i < length; ++i) { + char ch = text[i]; + if (ascii_letter(ch)) { + has_letter = 1; + } else if (ch >= '0' && ch <= '9') { + has_digit = 1; + } else { + return 0; + } + } + + return has_letter && has_digit; +} + +static int valid_zone_name(const char *text) { + if (!valid_display_text(text, ZONE_LIMIT)) return 0; + return !looks_like_internal_zone_code(text); +} + +static int valid_guild_name(const char *text) { + if (!valid_display_text(text, GUILD_LIMIT)) return 0; + return _stricmp(text, "none") != 0; +} + +static int text_from_location(const MemoryView *view, uintptr_t vanilla_va, + char *output, size_t output_size, size_t max_chars, + int (*validator)(const char *)) { + uintptr_t location; + uint32_t indirect = 0; + if (!view || !validator) return 0; + location = client_address(view, vanilla_va); + if (!location) return 0; + + if (memory_ascii(view, location, output, output_size, max_chars) && validator(output)) + return 1; + + output[0] = 0; + if (!memory_u32(view, location, &indirect) || !user_address((uintptr_t)indirect)) return 0; + if (memory_ascii(view, (uintptr_t)indirect, output, output_size, max_chars) && validator(output)) + return 1; + output[0] = 0; + return 0; +} + +static int first_text_match(const MemoryView *view, const uintptr_t *locations, + char *output, size_t output_size, size_t max_chars, + int (*validator)(const char *)) { + size_t i; + if (!locations) return 0; + for (i = 0u; locations[i] != 0u; ++i) { + if (text_from_location(view, locations[i], output, output_size, max_chars, validator)) + return 1; + } + if (output && output_size) output[0] = 0; + return 0; +} + +static const char *label_for_id(const LabelEntry *table, uint32_t id) { + size_t i; + if (!table || !id) return ""; + for (i = 0u; table[i].id != 0u; ++i) { + if (table[i].id == id) return table[i].text; + } + return ""; +} + +static const char *faction_for_race(uint32_t race_id) { + switch (race_id) { + case 1u: case 3u: case 4u: case 7u: case 10u: case 11u: case 16u: + return "alliance"; + case 2u: case 5u: case 6u: case 8u: case 9u: + return "horde"; + default: + return ""; + } +} + +static int object_manager_pointer(const MemoryView *view, uint32_t *manager) { + uintptr_t slot; + uint32_t value = 0; + if (!view || !manager) return 0; + slot = client_address(view, CLIENT_OBJECT_MANAGER_VA); + if (!slot || !memory_u32(view, slot, &value) || !user_address((uintptr_t)value)) return 0; + *manager = value; + return 1; +} + +static int stable_local_guid(const MemoryView *view, uint32_t manager, uint64_t *guid) { + uint64_t first = 0, second = 0; + uintptr_t address; + if (!view || !guid || !user_address((uintptr_t)manager)) return 0; + address = (uintptr_t)manager + OM_LOCAL_GUID_OFF; + if (!memory_u64(view, address, &first) || !memory_u64(view, address, &second)) return 0; + if (!first || first != second) return 0; + *guid = first; + return 1; +} + +static int contains_guild_marker(const char *text) { + char lowered[32]; + size_t i, length; + if (!text) return 0; + length = strlen(text); + if (length >= sizeof(lowered)) length = sizeof(lowered) - 1u; + for (i = 0u; i < length; ++i) { + char ch = text[i]; + lowered[i] = (ch >= 'A' && ch <= 'Z') ? (char)(ch - 'A' + 'a') : ch; + } + lowered[length] = 0; + return strstr(lowered, "guild") != NULL || strstr(lowered, "wgld") != NULL; +} + +static uintptr_t locate_guild_cache(const MemoryView *view) { + unsigned i; + for (i = 0u; i < GUILD_CACHE_COUNT; ++i) { + uintptr_t candidate = client_address( + view, GUILD_CACHE_BASE_VA + (uintptr_t)i * GUILD_CACHE_STRIDE); + uint32_t tag = 0; + uint32_t file_name_ptr = 0; + char file_name[32]; + + if (!candidate || !readable_range(candidate, 0x30u)) continue; + if (memory_u32(view, candidate + 0x28u, &tag) && + (tag == GUILD_TAG_WGLD || tag == GUILD_TAG_DLGW)) { + return candidate; + } + + file_name[0] = 0; + if (memory_u32(view, candidate + 0x2Cu, &file_name_ptr) && + user_address((uintptr_t)file_name_ptr) && + memory_ascii(view, (uintptr_t)file_name_ptr, file_name, sizeof(file_name), 24u) && + contains_guild_marker(file_name)) { + return candidate; + } + } + return 0; +} + +static int guild_name_from_chain(const MemoryView *view, uint32_t head, uint32_t guild_id, + uint32_t link_offset, char *output, size_t output_size) { + uint32_t node = head; + unsigned visited = 0u; + if (!view || !output || output_size < 2u) return 0; + output[0] = 0; + + while (user_address((uintptr_t)node) && !(node & 1u) && visited++ < MAX_GUILD_VISITS) { + uint32_t key = 0, next = 0; + if (!memory_u32(view, (uintptr_t)node, &key)) return 0; + if (key == guild_id) { + if (memory_ascii(view, (uintptr_t)node + 0x1Cu, output, output_size, GUILD_LIMIT) && + valid_guild_name(output)) return 1; + output[0] = 0; + if (memory_ascii(view, (uintptr_t)node + 0x18u, output, output_size, GUILD_LIMIT) && + valid_guild_name(output)) return 1; + output[0] = 0; + return 0; + } + if (!memory_u32(view, (uintptr_t)node + link_offset, &next) || + next == node || !user_address((uintptr_t)next)) { + return 0; + } + node = next; + } + return 0; +} + +static int resolve_guild_name(const MemoryView *view, uint32_t guild_id, + char *output, size_t output_size) { + uintptr_t cache; + uint32_t buckets = 0, mask = 0, head = 0; + uintptr_t bucket_head; + if (!guild_id || !output || output_size < 2u) return 0; + output[0] = 0; + + cache = locate_guild_cache(view); + if (!cache) return 0; + if (!memory_u32(view, cache + 0x1Cu, &buckets) || !user_address((uintptr_t)buckets)) return 0; + if (!memory_u32(view, cache + 0x24u, &mask)) return 0; + + bucket_head = (uintptr_t)buckets + (uintptr_t)(guild_id & mask) * 12u + 8u; + if (!memory_u32(view, bucket_head, &head) || !user_address((uintptr_t)head)) return 0; + if (guild_name_from_chain(view, head, guild_id, 4u, output, output_size)) return 1; + return guild_name_from_chain(view, head, guild_id, 8u, output, output_size); +} + +static void read_player_guild(const MemoryView *view, uintptr_t player_object, + uint32_t descriptors, char *output, size_t output_size) { + uint32_t guild_id = 0, player_info = 0; + if (!output || !output_size) return; + output[0] = 0; + + if (user_address((uintptr_t)descriptors) && + memory_u32(view, (uintptr_t)descriptors + PLAYER_GUILD_ID_OFF, &guild_id) && guild_id && + resolve_guild_name(view, guild_id, output, output_size)) { + return; + } + + output[0] = 0; + guild_id = 0; + if (memory_u32(view, player_object + PLAYER_INFO_OFF, &player_info) && + user_address((uintptr_t)player_info) && + memory_u32(view, (uintptr_t)player_info + PLAYER_INFO_GUILD_KEY_OFF, &guild_id) && + guild_id) { + resolve_guild_name(view, guild_id, output, output_size); + } +} + +static int read_local_player(const MemoryView *view, CharacterSnapshot *snapshot) { + uint32_t manager = 0, current = 0, initial_first = 0; + uint64_t wanted_guid = 0; + unsigned visited = 0u; + + if (!view || !snapshot || !object_manager_pointer(view, &manager)) return 0; + if (!stable_local_guid(view, manager, &wanted_guid)) return 0; + if (!memory_u32(view, (uintptr_t)manager + OM_FIRST_OBJECT_OFF, ¤t) || + !user_address((uintptr_t)current)) return 0; + initial_first = current; + + while (user_address((uintptr_t)current) && !(current & 1u) && visited++ < MAX_OBJECT_VISITS) { + uint32_t current_first = 0, object_type = 0, descriptors = 0, next = 0; + uint64_t object_guid = 0; + + if (!memory_u32(view, (uintptr_t)manager + OM_FIRST_OBJECT_OFF, ¤t_first) || + current_first != initial_first) return 0; + if (!memory_u32(view, (uintptr_t)current + OBJECT_TYPE_OFF, &object_type)) return 0; + + if (object_type == PLAYER_OBJECT_TYPE && + memory_u64(view, (uintptr_t)current + OBJECT_GUID_OFF, &object_guid) && + object_guid == wanted_guid && + memory_u32(view, (uintptr_t)current + OBJECT_DESCRIPTORS_OFF, &descriptors) && + user_address((uintptr_t)descriptors)) { + uint32_t level = 0, bytes0 = 0; + if (memory_u32(view, (uintptr_t)descriptors + UNIT_LEVEL_OFF, &level) && + level >= 1u && level <= 80u) { + snapshot->level = level; + } + if (memory_u32(view, (uintptr_t)descriptors + UNIT_BYTES0_OFF, &bytes0)) { + snapshot->race_id = bytes0 & 0xFFu; + snapshot->class_id = (bytes0 >> 8) & 0xFFu; + } + read_player_guild(view, (uintptr_t)current, descriptors, + snapshot->guild, sizeof(snapshot->guild)); + return 1; + } + + if (!memory_u32(view, (uintptr_t)current + OBJECT_NEXT_OFF, &next) || + next == current || !user_address((uintptr_t)next)) return 0; + current = next; + } + return 0; +} + +static void collect_character_snapshot(CharacterSnapshot *snapshot) { + MemoryView view; + DWORD now; + int have_name, have_zone; + if (!snapshot) return; + memset(snapshot, 0, sizeof(*snapshot)); + memory_view_init(&view); + + have_name = first_text_match(&view, kPlayerNameLocations, snapshot->name, + sizeof(snapshot->name), NAME_LIMIT, valid_player_name); + have_zone = first_text_match(&view, kZoneNameLocations, snapshot->zone, + sizeof(snapshot->zone), ZONE_LIMIT, valid_zone_name); + snapshot->in_world = have_name && have_zone; + + now = GetTickCount(); + if (!snapshot->in_world) { + gWorldReadySince = 0; + return; + } + + if (!gWorldReadySince) gWorldReadySince = now ? now : 1u; + if ((DWORD)(now - gWorldReadySince) < WORLD_SETTLE_MS) return; + read_local_player(&view, snapshot); +} + +static int game_folder(char *output, size_t output_size) { + char path[MAX_PATH]; + char *separator; + size_t length; + if (!output || output_size < 4u) return 0; + if (!GetModuleFileNameA(NULL, path, MAX_PATH)) return 0; + separator = strrchr(path, '\\'); + if (!separator) separator = strrchr(path, '/'); + if (!separator) return 0; + *separator = 0; + length = strlen(path); + if (length + 1u > output_size) return 0; + memcpy(output, path, length + 1u); + return 1; +} + +static int support_folder(char *output, size_t output_size) { + char root[MAX_PATH], parent[MAX_PATH]; + int count; + if (!game_folder(root, sizeof(root))) return 0; + count = _snprintf(parent, sizeof(parent), "%s\\.modernization_tool", root); + parent[sizeof(parent) - 1] = 0; + if (count < 0 || (size_t)count >= sizeof(parent)) return 0; + CreateDirectoryA(parent, NULL); + + count = _snprintf(output, output_size, "%s\\WowPresence", parent); + if (output_size) output[output_size - 1] = 0; + if (count < 0 || (size_t)count >= output_size) return 0; + CreateDirectoryA(output, NULL); + return 1; +} + +static int support_path(char *output, size_t output_size, const char *file_name) { + char folder[MAX_PATH]; + int count; + if (!file_name || !support_folder(folder, sizeof(folder))) return 0; + count = _snprintf(output, output_size, "%s\\%s", folder, file_name); + if (output_size) output[output_size - 1] = 0; + return count >= 0 && (size_t)count < output_size; +} + +static unsigned load_share_mask(void) { + char path[MAX_PATH], text[32]; + HANDLE file; + DWORD read_count = 0; + char *end = NULL; + unsigned long value; + memset(text, 0, sizeof(text)); + + if (!support_path(path, sizeof(path), "discord_broadcast_flags")) return SHARE_ALL; + 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 SHARE_ALL; + if (!ReadFile(file, text, sizeof(text) - 1u, &read_count, NULL) || !read_count) { + CloseHandle(file); + return SHARE_ALL; + } + CloseHandle(file); + text[read_count < sizeof(text) ? read_count : sizeof(text) - 1u] = 0; + value = strtoul(text, &end, 10); + if (end == text) return SHARE_ALL; + return (unsigned)(value & SHARE_ALL); +} + +static void json_quote_text(const char *source, char *output, size_t output_size) { + size_t read_index = 0u, write_index = 0u; + if (!output || !output_size) return; + output[0] = 0; + + while (source && source[read_index] && write_index + 1u < output_size) { + unsigned char ch = (unsigned char)source[read_index++]; + if (ch == '"' || ch == '\\') { + if (write_index + 2u >= output_size) break; + output[write_index++] = '\\'; + output[write_index++] = (char)ch; + } else if (ch >= 32u && ch < 127u) { + output[write_index++] = (char)ch; + } + } + output[write_index] = 0; +} + +static int replace_text_file_atomically(const char *file_name, const char *contents) { + char destination[MAX_PATH], temporary[MAX_PATH]; + HANDLE file; + DWORD length, written = 0; + int count; + + if (!file_name || !contents || !support_path(destination, sizeof(destination), file_name)) return 0; + count = _snprintf(temporary, sizeof(temporary), "%s.tmp", destination); + temporary[sizeof(temporary) - 1] = 0; + if (count < 0 || (size_t)count >= sizeof(temporary)) return 0; + + file = CreateFileA(temporary, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, NULL); + if (file == INVALID_HANDLE_VALUE) return 0; + length = (DWORD)strlen(contents); + if (!WriteFile(file, contents, length, &written, NULL) || written != length) { + CloseHandle(file); + DeleteFileA(temporary); + return 0; + } + FlushFileBuffers(file); + CloseHandle(file); + + if (!MoveFileExA(temporary, destination, + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + DeleteFileA(temporary); + return 0; + } + return 1; +} + +static void publish_fault_snapshot(void) { + replace_text_file_atomically( + "discord_wow_status.json", + "{\"v\":1,\"ts\":0,\"ok\":false,\"in_world\":false,\"name\":\"\"," + "\"zone\":\"\",\"level\":0,\"faction\":\"\",\"class\":\"\"," + "\"guild\":\"\",\"race\":\"\",\"build\":5875,\"err\":\"fault\"}"); +} + +static void publish_character_snapshot(void) { + CharacterSnapshot snapshot; + unsigned mask; + char safe_name[NAME_LIMIT * 2u + 8u]; + char safe_zone[ZONE_LIMIT * 2u + 8u]; + char safe_guild[GUILD_LIMIT * 2u + 8u]; + char json[JSON_BUFFER_SIZE]; + const char *race_text, *class_text, *faction_text; + + collect_character_snapshot(&snapshot); + mask = load_share_mask(); + race_text = snapshot.in_world ? label_for_id(kRaceLabels, snapshot.race_id) : ""; + class_text = (snapshot.in_world && (mask & SHARE_CLASS)) + ? label_for_id(kClassLabels, snapshot.class_id) : ""; + faction_text = (snapshot.in_world && (mask & SHARE_FACTION)) + ? faction_for_race(snapshot.race_id) : ""; + + json_quote_text((mask & SHARE_NAME) ? snapshot.name : "", safe_name, sizeof(safe_name)); + json_quote_text((mask & SHARE_ZONE) ? snapshot.zone : "", safe_zone, sizeof(safe_zone)); + json_quote_text((mask & SHARE_GUILD) ? snapshot.guild : "", safe_guild, sizeof(safe_guild)); + + _snprintf( + json, sizeof(json), + "{\"v\":1,\"ts\":%ld,\"ok\":%s,\"in_world\":%s," + "\"name\":\"%s\",\"zone\":\"%s\",\"level\":%u," + "\"faction\":\"%s\",\"class\":\"%s\",\"guild\":\"%s\"," + "\"race\":\"%s\",\"build\":5875,\"err\":\"%s\"}", + (long)time(NULL), + snapshot.in_world ? "true" : "false", + snapshot.in_world ? "true" : "false", + safe_name, + safe_zone, + (snapshot.in_world && (mask & SHARE_LEVEL)) ? snapshot.level : 0u, + faction_text, + class_text, + safe_guild, + race_text, + snapshot.in_world ? "" : "offsets"); + json[sizeof(json) - 1] = 0; + replace_text_file_atomically("discord_wow_status.json", json); +} + +static int launch_discord_companion(void) { + char root[MAX_PATH], executable[MAX_PATH], command[MAX_PATH * 2u]; + STARTUPINFOA startup; + PROCESS_INFORMATION process; + DWORD attributes; + int count; + + if (!game_folder(root, sizeof(root))) return 0; + count = _snprintf(executable, sizeof(executable), "%s\\WowPresence.exe", root); + executable[sizeof(executable) - 1] = 0; + if (count < 0 || (size_t)count >= sizeof(executable)) return 0; + + attributes = GetFileAttributesA(executable); + if (attributes == INVALID_FILE_ATTRIBUTES || (attributes & FILE_ATTRIBUTE_DIRECTORY)) return 0; + + count = _snprintf(command, sizeof(command), "\"%s\" --pid %lu", + executable, (unsigned long)GetCurrentProcessId()); + command[sizeof(command) - 1] = 0; + if (count < 0 || (size_t)count >= sizeof(command)) return 0; + + memset(&startup, 0, sizeof(startup)); + memset(&process, 0, sizeof(process)); + startup.cb = sizeof(startup); + startup.dwFlags = STARTF_USESHOWWINDOW; + startup.wShowWindow = SW_HIDE; + + if (!CreateProcessA(executable, command, NULL, NULL, FALSE, CREATE_NO_WINDOW, + NULL, root, &startup, &process)) { + return 0; + } + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + return 1; +} + +static DWORD WINAPI presence_worker(LPVOID parameter) { + int companion_running = 0; + (void)parameter; + + if (WaitForSingleObject(gStopEvent, STARTUP_WAIT_MS) != WAIT_TIMEOUT) return 0; + do { +#ifdef _MSC_VER + __try { + publish_character_snapshot(); + } __except (EXCEPTION_EXECUTE_HANDLER) { + publish_fault_snapshot(); + } +#else + publish_character_snapshot(); +#endif + if (!companion_running) companion_running = launch_discord_companion(); + } while (WaitForSingleObject(gStopEvent, SAMPLE_PERIOD_MS) == WAIT_TIMEOUT); + return 0; +} + +BOOL WINAPI DllMain(HINSTANCE module, DWORD reason, LPVOID reserved) { + (void)reserved; + if (reason == DLL_PROCESS_ATTACH) { + DisableThreadLibraryCalls(module); + gStopEvent = CreateEventA(NULL, TRUE, FALSE, NULL); + if (gStopEvent) gWorkerThread = CreateThread(NULL, 0, presence_worker, NULL, 0, NULL); + } else if (reason == DLL_PROCESS_DETACH) { + if (gStopEvent) SetEvent(gStopEvent); + if (gWorkerThread) { + WaitForSingleObject(gWorkerThread, 1500u); + CloseHandle(gWorkerThread); + gWorkerThread = NULL; + } + if (gStopEvent) { + CloseHandle(gStopEvent); + gStopEvent = NULL; + } + } + return TRUE; +} diff --git a/src/WowPresenceLauncher.c b/src/WowPresenceLauncher.c new file mode 100644 index 0000000..047fb35 --- /dev/null +++ b/src/WowPresenceLauncher.c @@ -0,0 +1,504 @@ +/* + * WowPresence.exe + * Invisible companion launcher for Modernization Tool. + * + * - is started automatically by WowPresence.dll after WoW is running + * - reads .modernization_tool\DiscordPresence\discord_wow_status.json + * - publishes Discord Rich Presence over the local Discord IPC named pipe + * - exits when WoW_Modernized.exe exits + */ +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include +#include + +#define STATUS_MAX_AGE 30 +#define TICK_MS 2000 +#define PIPE_COUNT 10 +#define IPC_OPCODE_HANDSHAKE 0u +#define IPC_OPCODE_FRAME 1u +#define DEFAULT_DISCORD_APPLICATION_ID "1544072796098011176" + +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 support_directory(char *out, size_t out_size) { + char root[MAX_PATH], first[MAX_PATH]; + if (!own_directory(root, sizeof(root))) return 0; + if (strlen(root) + strlen("\\.modernization_tool") + 1 >= sizeof(first)) return 0; + _snprintf(first, sizeof(first), "%s\\.modernization_tool", root); + first[sizeof(first) - 1] = 0; + CreateDirectoryA(first, NULL); + if (strlen(first) + strlen("\\WowPresence") + 1 >= out_size) return 0; + _snprintf(out, out_size, "%s\\WowPresence", first); + 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 override_id[64] = {0}; + + if (!out || out_size <= strlen(DEFAULT_DISCORD_APPLICATION_ID)) return 0; + lstrcpynA(out, DEFAULT_DISCORD_APPLICATION_ID, (int)out_size); + + /* Optional advanced override. Normal users never need this file. */ + if (support_file(path, sizeof(path), "discord_application_id") && + read_text_file(path, override_id, sizeof(override_id))) { + trim_ascii(override_id); + if (valid_application_id(override_id)) { + lstrcpynA(out, override_id, (int)out_size); + } else if (override_id[0]) { + log_line("Ignoring invalid discord_application_id override; using built-in OctoWoW ID."); + } + } + + return valid_application_id(out); +} + +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; +} + +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)); + } + + _snprintf(out, out_size, + "{\"details\":\"%s\",\"state\":\"%s\",\"timestamps\":{\"start\":%lld}}", + details, s->zone, 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("Built-in Discord Application ID is invalid; presence is disabled."); + } + + /* 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) Sleep(200); + } + if (!pid) { + log_line("No running WoW_Modernized.exe 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_Modernized.exe 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; +} -- 2.52.0 From 503103179d39c987e86d7189c069f6a3137c39f5 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 15:44:08 +0200 Subject: [PATCH 02/35] Refresh README presentation --- README.md | 164 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 92 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 25e62a5..3b51b11 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,58 @@ -# WowPresence +# 🎮 WowPresence Native Discord Rich Presence for **World of Warcraft 1.12.1 (build 5875)** and compatible clients. -WowPresence keeps the game-side component deliberately small: a read-only DLL samples character status from the WoW process and writes a local JSON snapshot, while a separate companion executable handles Discord IPC outside the game process. +Focused on a **small game-side footprint, stable character detection and native Discord IPC**. -## Features +> WowPresence targets **WoW 1.12.1 / build 5875-compatible client environments**. -- Character name and guild -- Level and class -- Race and faction -- Current zone -- Stable elapsed-session timer across character changes -- Automatic companion startup -- Exact WoW process tracking -- Read-only game-memory access -- No Discord bot, Client Secret, or network service required +## 🔌 Requirements -## Compatibility +- **World of Warcraft 1.12.1 (build 5875)** or a compatible client +- **Discord Desktop** +- A compatible DLL loader such as **VanillaFixes** + +The DLL only samples character information from the game. Discord communication is handled by a separate companion process. + +## 📦 Installation + +1. Download **`WowPresence.dll`** and **`WowPresence.exe`** from Releases. +2. Place both files in your WoW game directory. +3. Add `WowPresence.dll` to your VanillaFixes `dlls.txt`. +4. Launch WoW normally through VanillaFixes. + +`WowPresence.exe` is started automatically by the DLL and normally does **not** need to be launched manually. + +Runtime files are created automatically under: + +```text +\.modernization_tool\WowPresence\ +``` + +## ✨ Features + +- Discord Rich Presence for WoW 1.12.1. +- Character name and guild display. +- Level and class display. +- Race and faction display. +- Current zone display. +- Stable elapsed-session timer across character changes. +- Automatic companion startup. +- Exact WoW process tracking. +- Read-only game-memory sampling. +- Native Discord IPC with no bot or Client Secret required. +- Optional privacy flags for individual character fields. + +## 🔧 Compatibility ### Tested -- OctoWoW client based on WoW 1.12.1 / build 5875 +- **OctoWoW** — WoW 1.12.1 / build 5875 based client -### Expected compatibility +### Expected compatible -- World of Warcraft 1.12.1 build 5875 -- Compatible private-server clients that preserve the same relevant client memory layout +- **World of Warcraft 1.12.1 build 5875** +- Private-server clients preserving the relevant 1.12.1 client memory layout ### Not compatible @@ -36,38 +63,36 @@ WowPresence keeps the game-side component deliberately small: a read-only DLL sa Other modified 1.12.1 clients should be considered **untested until verified**. -## Files +## ⚙️ How it works ```text -WowPresence.dll -WowPresence.exe +WoW + └─ WowPresence.dll + ├─ read-only character sampling + ├─ local JSON status + └─ starts WowPresence.exe + └─ Discord local IPC ``` -The DLL is loaded into WoW by a compatible DLL loader such as VanillaFixes. After WoW is running, the DLL automatically starts `WowPresence.exe`. +The DLL does not communicate directly with Discord. -The companion executable should normally **not** be launched manually. +The companion executable does not read or write WoW memory. -## Installation - -1. Download `WowPresence.dll` and `WowPresence.exe` from Releases. -2. Place both files in the WoW game directory. -3. Add this line to the VanillaFixes `dlls.txt` file: +Character status is written to: ```text -WowPresence.dll +.modernization_tool\WowPresence\discord_wow_status.json ``` -4. Launch WoW normally through VanillaFixes. - -WowPresence creates its runtime files under: +The companion log is written to: ```text -\.modernization_tool\WowPresence\ +.modernization_tool\WowPresence\WowPresence.log ``` -## Discord application +## 🛰️ Discord application -The current build includes the OctoWoW Discord Application ID as its default so it works immediately with the configuration it was originally developed and tested against. +WowPresence currently includes the **OctoWoW Discord Application ID** as its built-in default. Advanced users can override it by creating: @@ -75,11 +100,11 @@ Advanced users can override it by creating: .modernization_tool\WowPresence\discord_application_id ``` -The file should contain only the numeric Discord Application ID. +The file must contain only the numeric Discord Application ID. -No Client Secret is used or required. +No Discord Client Secret is used or required. -## Privacy flags +## 🔒 Privacy flags The optional file: @@ -99,44 +124,19 @@ controls which character details are published to Discord. | 32 | Zone | | 63 | All fields | -If the file is missing, all supported fields are enabled. +If the file does not exist, all supported fields are enabled. -## Administrator privileges +## 🛡️ Administrator privileges Discord and WoW should run with matching privilege levels. -If Discord is running as administrator, WoW must also run as administrator for local Discord Rich Presence IPC to work reliably. +If Discord runs as administrator, WoW must also run as administrator for local Rich Presence IPC to work reliably. -Running both normally is recommended unless elevation is actually required by the local setup. +Running both normally is recommended unless elevation is required by the local setup. -## Runtime design +## 🛠️ Building -```text -WoW - └─ WowPresence.dll - ├─ read-only character sampling - ├─ local JSON status - └─ starts WowPresence.exe - └─ Discord local IPC -``` - -The DLL does not communicate directly with Discord. The companion executable does not read or write WoW memory. - -Status data is written to: - -```text -.modernization_tool\WowPresence\discord_wow_status.json -``` - -The companion log is written to: - -```text -.modernization_tool\WowPresence\WowPresence.log -``` - -## Building - -The native components target **32-bit x86 Windows**, matching WoW 1.12.1. +WowPresence targets **32-bit x86 Windows**, matching WoW 1.12.1. With an x86 MSVC developer environment: @@ -145,12 +145,32 @@ cl /nologo /O2 /MT /W3 /LD src\WowPresence.c /Fe:WowPresence.dll /link /INCREMEN cl /nologo /O2 /MT /W3 src\WowPresenceLauncher.c user32.lib /Fe:WowPresence.exe /link /SUBSYSTEM:WINDOWS /INCREMENTAL:NO ``` -GitHub Actions also builds both binaries automatically. Tagged versions matching `v*` publish a GitHub Release containing the DLL, EXE, and ZIP package. +GitHub Actions builds: -## Credits and provenance +- `WowPresence.dll` +- `WowPresence.exe` +- `WowPresence.zip` + +Tags matching `v*` can publish these files as a GitHub Release. + +## 📜 Project identity & licensing + +WowPresence is an **independent community project**. + +Compatibility with **World of Warcraft**, **Discord**, **OctoWoW**, **VanillaFixes**, **IchaLaunch**, or other referenced projects does not imply affiliation, endorsement, sponsorship, or ownership by their respective rights holders or maintainers. + +**World of Warcraft**, **Warcraft**, **Blizzard Entertainment**, **Discord**, and related names and marks remain the property of their respective rights holders. + +This repository does not currently declare a software license. + +For detailed provenance and third-party information, see: + +- [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) + +## 🙏 Credits WowPresence is maintained by **Dusk-92**. -The project was inspired in part by the WoW character-status / Discord Rich Presence work in **IchaLaunch** by **brutaliccus**. The current implementation is maintained separately and does not include IchaLaunch binaries. +The project was inspired in part by the WoW character-status / Discord Rich Presence implementation in **IchaLaunch** by **brutaliccus**. -See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for additional provenance and trademark information. +The current WowPresence implementation is maintained separately and does not include IchaLaunch binaries. -- 2.52.0 From cf1aa736efde72d879024291592b4d44d8b91fc0 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 15:51:14 +0200 Subject: [PATCH 03/35] Require configured Discord Application ID --- src/WowPresenceLauncher.c | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/WowPresenceLauncher.c b/src/WowPresenceLauncher.c index 047fb35..449c11d 100644 --- a/src/WowPresenceLauncher.c +++ b/src/WowPresenceLauncher.c @@ -1,6 +1,6 @@ /* * WowPresence.exe - * Invisible companion launcher for Modernization Tool. + * Invisible companion process for WowPresence. * * - is started automatically by WowPresence.dll after WoW is running * - reads .modernization_tool\DiscordPresence\discord_wow_status.json @@ -21,7 +21,6 @@ #define PIPE_COUNT 10 #define IPC_OPCODE_HANDSHAKE 0u #define IPC_OPCODE_FRAME 1u -#define DEFAULT_DISCORD_APPLICATION_ID "1544072796098011176" typedef struct { char name[32]; @@ -138,23 +137,25 @@ static int valid_application_id(const char *text) { static int load_application_id(char *out, size_t out_size) { char path[MAX_PATH]; - char override_id[64] = {0}; + char configured_id[64] = {0}; - if (!out || out_size <= strlen(DEFAULT_DISCORD_APPLICATION_ID)) return 0; - lstrcpynA(out, DEFAULT_DISCORD_APPLICATION_ID, (int)out_size); + if (!out || out_size < 25u) return 0; + out[0] = 0; - /* Optional advanced override. Normal users never need this file. */ - if (support_file(path, sizeof(path), "discord_application_id") && - read_text_file(path, override_id, sizeof(override_id))) { - trim_ascii(override_id); - if (valid_application_id(override_id)) { - lstrcpynA(out, override_id, (int)out_size); - } else if (override_id[0]) { - log_line("Ignoring invalid discord_application_id override; using built-in OctoWoW ID."); - } + 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 .modernization_tool\\WowPresence\\discord_application_id."); + return 0; } - return valid_application_id(out); + 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) { @@ -442,7 +443,7 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE prev, LPSTR cmdline, int show) have_id = load_application_id(application_id, sizeof(application_id)); if (!have_id) { - log_line("Built-in Discord Application ID is invalid; presence is disabled."); + 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. -- 2.52.0 From 22ee3b39702f9ff027a7eeea15b91d332529c5d3 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 15:51:23 +0200 Subject: [PATCH 04/35] Refresh standalone WowPresence source identity --- src/WowPresence.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WowPresence.c b/src/WowPresence.c index 66ecf45..a683c77 100644 --- a/src/WowPresence.c +++ b/src/WowPresence.c @@ -1,6 +1,6 @@ /* * WowPresence.dll - * Read-only WoW 1.12.1 (build 5875) status sampler for Modernization Tool. + * Read-only WoW 1.12.1 (build 5875) status sampler for WowPresence. * * Runtime contract: * \.modernization_tool\DiscordPresence\discord_wow_status.json -- 2.52.0 From d2bf4c088345aa621f9822f8bc1771aff34b846d Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 15:51:40 +0200 Subject: [PATCH 05/35] Package ready-to-edit WowPresence config --- .github/workflows/build.yml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b577716..cfc1670 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,7 @@ permissions: contents: write concurrency: - group: wowpresence-${{ github.workflow }}-${{ github.ref }} + group: wowpresence-\${{ github.workflow }}-\${{ github.ref }} cancel-in-progress: true jobs: @@ -44,16 +44,25 @@ jobs: if (-not (Test-Path "dist\WowPresence.dll")) { throw "WowPresence.dll missing" } if (-not (Test-Path "dist\WowPresence.exe")) { throw "WowPresence.exe missing" } - - name: Create ZIP package + - name: Create ready-to-install package shell: pwsh run: | + $package = "package" + $config = Join-Path $package ".modernization_tool\WowPresence" + New-Item -ItemType Directory -Force -Path $config | Out-Null + + Copy-Item "dist\WowPresence.dll" (Join-Path $package "WowPresence.dll") + Copy-Item "dist\WowPresence.exe" (Join-Path $package "WowPresence.exe") + Copy-Item "config\discord_application_id" (Join-Path $config "discord_application_id") + Copy-Item "config\discord_broadcast_flags" (Join-Path $config "discord_broadcast_flags") + $zip = "dist\WowPresence.zip" if (Test-Path $zip) { Remove-Item $zip -Force } - Compress-Archive -Path "dist\WowPresence.dll","dist\WowPresence.exe" -DestinationPath $zip -CompressionLevel Optimal + Compress-Archive -Path (Join-Path $package "WowPresence.dll"),(Join-Path $package "WowPresence.exe"),(Join-Path $package ".modernization_tool") -DestinationPath $zip -CompressionLevel Optimal if (-not (Test-Path $zip)) { throw "WowPresence.zip missing" } - name: Upload build artifact - if: ${{ !startsWith(github.ref, 'refs/tags/') }} + if: \${{ !startsWith(github.ref, 'refs/tags/') }} uses: actions/upload-artifact@v4 with: name: WowPresence-build @@ -64,7 +73,7 @@ jobs: if-no-files-found: error - name: Create GitHub Release - if: ${{ startsWith(github.ref, 'refs/tags/') }} + if: \${{ startsWith(github.ref, 'refs/tags/') }} uses: softprops/action-gh-release@v3 with: files: | @@ -72,6 +81,6 @@ jobs: dist/WowPresence.exe dist/WowPresence.zip fail_on_unmatched_files: true - name: "WowPresence ${{ github.ref_name }}" + name: "WowPresence \${{ github.ref_name }}" generate_release_notes: true make_latest: true -- 2.52.0 From 3918f1da67940cfcd90a407dacb82b910a0d4403 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 15:51:50 +0200 Subject: [PATCH 06/35] Add Discord Application ID template --- config/discord_application_id | 1 + 1 file changed, 1 insertion(+) create mode 100644 config/discord_application_id diff --git a/config/discord_application_id b/config/discord_application_id new file mode 100644 index 0000000..004d63e --- /dev/null +++ b/config/discord_application_id @@ -0,0 +1 @@ +PASTE_YOUR_DISCORD_APPLICATION_ID_HERE -- 2.52.0 From d3a2c1f8425a8bfd6a2faa59863f39cf0c4fa8a0 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 15:51:54 +0200 Subject: [PATCH 07/35] Add default broadcast flags --- config/discord_broadcast_flags | 1 + 1 file changed, 1 insertion(+) create mode 100644 config/discord_broadcast_flags diff --git a/config/discord_broadcast_flags b/config/discord_broadcast_flags new file mode 100644 index 0000000..4b9026d --- /dev/null +++ b/config/discord_broadcast_flags @@ -0,0 +1 @@ +63 -- 2.52.0 From 4fe7cfd406611d6056f333d4ba7c780784217667 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 15:52:21 +0200 Subject: [PATCH 08/35] Document ready-to-install Discord ID setup --- README.md | 109 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 71 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 3b51b11..99bfe2b 100644 --- a/README.md +++ b/README.md @@ -11,23 +11,64 @@ Focused on a **small game-side footprint, stable character detection and native - **World of Warcraft 1.12.1 (build 5875)** or a compatible client - **Discord Desktop** - A compatible DLL loader such as **VanillaFixes** +- A free **Discord Application ID** The DLL only samples character information from the game. Discord communication is handled by a separate companion process. ## 📦 Installation -1. Download **`WowPresence.dll`** and **`WowPresence.exe`** from Releases. -2. Place both files in your WoW game directory. -3. Add `WowPresence.dll` to your VanillaFixes `dlls.txt`. -4. Launch WoW normally through VanillaFixes. +1. Download **\`WowPresence.zip\`** from Releases. +2. Extract the ZIP directly into your WoW game directory. +3. Open: -`WowPresence.exe` is started automatically by the DLL and normally does **not** need to be launched manually. +\`\`\`text +.modernization_tool\WowPresence\discord_application_id +\`\`\` -Runtime files are created automatically under: +4. Replace the placeholder text with **your numeric Discord Application ID**. +5. Add \`WowPresence.dll\` to your VanillaFixes \`dlls.txt\`. +6. Launch WoW normally through VanillaFixes. -```text -\.modernization_tool\WowPresence\ -``` +After extraction: + +\`\`\`text +\ +├─ WowPresence.dll +├─ WowPresence.exe +└─ .modernization_tool\ + └─ WowPresence\ + ├─ discord_application_id + └─ discord_broadcast_flags +\`\`\` + +\`WowPresence.exe\` is started automatically by the DLL and normally does **not** need to be launched manually. + +## 🛰️ Discord Application ID + +WowPresence intentionally does **not** include a server-specific Discord Application ID. + +To create yours: + +1. Open the **Discord Developer Portal**: https://discord.com/developers/applications +2. Create a **New Application**. +3. Give it the name you want Discord to display. +4. Open **General Information**. +5. Copy the **Application ID**. +6. Paste only that numeric ID into: + +\`\`\`text +.modernization_tool\WowPresence\discord_application_id +\`\`\` + +Example: + +\`\`\`text +123456789012345678 +\`\`\` + +No Discord Client Secret, bot token, or bot account is used or required. + +If the file is missing or invalid, WowPresence leaves Rich Presence disabled and records the reason in \`WowPresence.log\`. ## ✨ Features @@ -65,14 +106,14 @@ Other modified 1.12.1 clients should be considered **untested until verified**. ## ⚙️ How it works -```text +\`\`\`text WoW └─ WowPresence.dll ├─ read-only character sampling ├─ local JSON status └─ starts WowPresence.exe └─ Discord local IPC -``` +\`\`\` The DLL does not communicate directly with Discord. @@ -80,37 +121,25 @@ The companion executable does not read or write WoW memory. Character status is written to: -```text +\`\`\`text .modernization_tool\WowPresence\discord_wow_status.json -``` +\`\`\` The companion log is written to: -```text +\`\`\`text .modernization_tool\WowPresence\WowPresence.log -``` +\`\`\` -## 🛰️ Discord application - -WowPresence currently includes the **OctoWoW Discord Application ID** as its built-in default. - -Advanced users can override it by creating: - -```text -.modernization_tool\WowPresence\discord_application_id -``` - -The file must contain only the numeric Discord Application ID. - -No Discord Client Secret is used or required. +These runtime files are created automatically. ## 🔒 Privacy flags -The optional file: +The included file: -```text +\`\`\`text .modernization_tool\WowPresence\discord_broadcast_flags -``` +\`\`\` controls which character details are published to Discord. @@ -124,7 +153,9 @@ controls which character details are published to Discord. | 32 | Zone | | 63 | All fields | -If the file does not exist, all supported fields are enabled. +The release package uses \`63\` by default. + +If the file is deleted, WowPresence also defaults to all supported fields. ## 🛡️ Administrator privileges @@ -140,18 +171,20 @@ WowPresence targets **32-bit x86 Windows**, matching WoW 1.12.1. With an x86 MSVC developer environment: -```bat +\`\`\`bat cl /nologo /O2 /MT /W3 /LD src\WowPresence.c /Fe:WowPresence.dll /link /INCREMENTAL:NO cl /nologo /O2 /MT /W3 src\WowPresenceLauncher.c user32.lib /Fe:WowPresence.exe /link /SUBSYSTEM:WINDOWS /INCREMENTAL:NO -``` +\`\`\` GitHub Actions builds: -- `WowPresence.dll` -- `WowPresence.exe` -- `WowPresence.zip` +- \`WowPresence.dll\` +- \`WowPresence.exe\` +- \`WowPresence.zip\` -Tags matching `v*` can publish these files as a GitHub Release. +The ZIP also includes the ready-to-edit configuration files. + +Tags matching \`v*\` can publish these files as a GitHub Release. ## 📜 Project identity & licensing -- 2.52.0 From 2aa16fbbc1b1ec215fd55d286f081145c389c983 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 15:52:48 +0200 Subject: [PATCH 09/35] Fix WowPresence workflow expressions --- .github/workflows/build.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cfc1670..b7e3a44 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,7 @@ permissions: contents: write concurrency: - group: wowpresence-\${{ github.workflow }}-\${{ github.ref }} + group: wowpresence-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: @@ -62,7 +62,7 @@ jobs: if (-not (Test-Path $zip)) { throw "WowPresence.zip missing" } - name: Upload build artifact - if: \${{ !startsWith(github.ref, 'refs/tags/') }} + if: ${{ !startsWith(github.ref, 'refs/tags/') }} uses: actions/upload-artifact@v4 with: name: WowPresence-build @@ -73,7 +73,7 @@ jobs: if-no-files-found: error - name: Create GitHub Release - if: \${{ startsWith(github.ref, 'refs/tags/') }} + if: ${{ startsWith(github.ref, 'refs/tags/') }} uses: softprops/action-gh-release@v3 with: files: | @@ -81,6 +81,6 @@ jobs: dist/WowPresence.exe dist/WowPresence.zip fail_on_unmatched_files: true - name: "WowPresence \${{ github.ref_name }}" + name: "WowPresence ${{ github.ref_name }}" generate_release_notes: true make_latest: true -- 2.52.0 From 374b69dc2de1a082e9978ccf8f1bedf4844c07b2 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 15:58:48 +0200 Subject: [PATCH 10/35] Use standalone WowPresence data folder --- src/WowPresence.c | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/WowPresence.c b/src/WowPresence.c index a683c77..0498809 100644 --- a/src/WowPresence.c +++ b/src/WowPresence.c @@ -542,15 +542,11 @@ static int game_folder(char *output, size_t output_size) { } static int support_folder(char *output, size_t output_size) { - char root[MAX_PATH], parent[MAX_PATH]; + char root[MAX_PATH]; int count; if (!game_folder(root, sizeof(root))) return 0; - count = _snprintf(parent, sizeof(parent), "%s\\.modernization_tool", root); - parent[sizeof(parent) - 1] = 0; - if (count < 0 || (size_t)count >= sizeof(parent)) return 0; - CreateDirectoryA(parent, NULL); - count = _snprintf(output, output_size, "%s\\WowPresence", parent); + count = _snprintf(output, output_size, "%s\\WowPresence", root); if (output_size) output[output_size - 1] = 0; if (count < 0 || (size_t)count >= output_size) return 0; CreateDirectoryA(output, NULL); -- 2.52.0 From 89efe5795def0604acb3bf76bcd98572bd1a5aee Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 15:59:03 +0200 Subject: [PATCH 11/35] Use standalone WowPresence data folder --- src/WowPresenceLauncher.c | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/WowPresenceLauncher.c b/src/WowPresenceLauncher.c index 449c11d..b6b58f0 100644 --- a/src/WowPresenceLauncher.c +++ b/src/WowPresenceLauncher.c @@ -55,14 +55,10 @@ static int own_directory(char *out, size_t out_size) { } static int support_directory(char *out, size_t out_size) { - char root[MAX_PATH], first[MAX_PATH]; + char root[MAX_PATH]; if (!own_directory(root, sizeof(root))) return 0; - if (strlen(root) + strlen("\\.modernization_tool") + 1 >= sizeof(first)) return 0; - _snprintf(first, sizeof(first), "%s\\.modernization_tool", root); - first[sizeof(first) - 1] = 0; - CreateDirectoryA(first, NULL); - if (strlen(first) + strlen("\\WowPresence") + 1 >= out_size) return 0; - _snprintf(out, out_size, "%s\\WowPresence", first); + 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; @@ -144,7 +140,7 @@ static int load_application_id(char *out, size_t out_size) { 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 .modernization_tool\\WowPresence\\discord_application_id."); + log_line("discord_application_id is missing. Add your Discord Application ID to WowPresence\\discord_application_id."); return 0; } -- 2.52.0 From 79d0744b332ecc782aef4dbbfa9707675872af69 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 15:59:49 +0200 Subject: [PATCH 12/35] Package standalone WowPresence folder --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b7e3a44..0c9ba07 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,7 +48,7 @@ jobs: shell: pwsh run: | $package = "package" - $config = Join-Path $package ".modernization_tool\WowPresence" + $config = Join-Path $package "WowPresence" New-Item -ItemType Directory -Force -Path $config | Out-Null Copy-Item "dist\WowPresence.dll" (Join-Path $package "WowPresence.dll") @@ -58,7 +58,7 @@ jobs: $zip = "dist\WowPresence.zip" if (Test-Path $zip) { Remove-Item $zip -Force } - Compress-Archive -Path (Join-Path $package "WowPresence.dll"),(Join-Path $package "WowPresence.exe"),(Join-Path $package ".modernization_tool") -DestinationPath $zip -CompressionLevel Optimal + Compress-Archive -Path (Join-Path $package "WowPresence.dll"),(Join-Path $package "WowPresence.exe"),(Join-Path $package "WowPresence") -DestinationPath $zip -CompressionLevel Optimal if (-not (Test-Path $zip)) { throw "WowPresence.zip missing" } - name: Upload build artifact -- 2.52.0 From 34ca3f6b8348cb921a3859836b8dce16449b289f Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 15:59:54 +0200 Subject: [PATCH 13/35] Refresh standalone runtime path comments --- src/WowPresence.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WowPresence.c b/src/WowPresence.c index 0498809..b10cceb 100644 --- a/src/WowPresence.c +++ b/src/WowPresence.c @@ -3,8 +3,8 @@ * Read-only WoW 1.12.1 (build 5875) status sampler for WowPresence. * * Runtime contract: - * \.modernization_tool\DiscordPresence\discord_wow_status.json - * \.modernization_tool\DiscordPresence\discord_broadcast_flags + * \WowPresence\discord_wow_status.json + * \WowPresence\discord_broadcast_flags * * Discord IPC is intentionally out of process. After the first sample, this * DLL starts WowPresence.exe from its worker thread and passes the exact -- 2.52.0 From 1fb85ac7ea87a174f8d8a9141c249f883eca204d Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 15:59:59 +0200 Subject: [PATCH 14/35] Refresh companion runtime path comment --- src/WowPresenceLauncher.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WowPresenceLauncher.c b/src/WowPresenceLauncher.c index b6b58f0..05d97fa 100644 --- a/src/WowPresenceLauncher.c +++ b/src/WowPresenceLauncher.c @@ -3,7 +3,7 @@ * Invisible companion process for WowPresence. * * - is started automatically by WowPresence.dll after WoW is running - * - reads .modernization_tool\DiscordPresence\discord_wow_status.json + * - reads WowPresence\discord_wow_status.json * - publishes Discord Rich Presence over the local Discord IPC named pipe * - exits when WoW_Modernized.exe exits */ -- 2.52.0 From c0d297c3b97fa774520dc6b50268bdc91ade4efa Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 16:00:28 +0200 Subject: [PATCH 15/35] Refresh README for standalone WowPresence folder --- README.md | 101 ++++++++++++++++++++++++++---------------------------- 1 file changed, 49 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 99bfe2b..45ce4fe 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Native Discord Rich Presence for **World of Warcraft 1.12.1 (build 5875)** and compatible clients. -Focused on a **small game-side footprint, stable character detection and native Discord IPC**. +Focused on **simple installation, read-only game sampling and native Discord IPC**. > WowPresence targets **WoW 1.12.1 / build 5875-compatible client environments**. @@ -13,35 +13,36 @@ Focused on a **small game-side footprint, stable character detection and native - A compatible DLL loader such as **VanillaFixes** - A free **Discord Application ID** -The DLL only samples character information from the game. Discord communication is handled by a separate companion process. +The DLL only reads character status from the game. Discord communication is handled by the separate `WowPresence.exe` companion process. ## 📦 Installation -1. Download **\`WowPresence.zip\`** from Releases. +1. Download **`WowPresence.zip`** from Releases. 2. Extract the ZIP directly into your WoW game directory. 3. Open: -\`\`\`text -.modernization_tool\WowPresence\discord_application_id -\`\`\` +```text +WowPresence\discord_application_id +``` -4. Replace the placeholder text with **your numeric Discord Application ID**. -5. Add \`WowPresence.dll\` to your VanillaFixes \`dlls.txt\`. +4. Replace the placeholder with **your numeric Discord Application ID**. +5. Add `WowPresence.dll` to your VanillaFixes `dlls.txt`. 6. Launch WoW normally through VanillaFixes. -After extraction: +After extraction, the relevant files should look like this: -\`\`\`text +```text \ ├─ WowPresence.dll ├─ WowPresence.exe -└─ .modernization_tool\ - └─ WowPresence\ - ├─ discord_application_id - └─ discord_broadcast_flags -\`\`\` +└─ WowPresence\ + ├─ discord_application_id + └─ discord_broadcast_flags +``` -\`WowPresence.exe\` is started automatically by the DLL and normally does **not** need to be launched manually. +`WowPresence.exe` is started automatically by the DLL and normally does **not** need to be launched manually. + +The runtime JSON and log files are created automatically inside the same `WowPresence` folder. ## 🛰️ Discord Application ID @@ -56,23 +57,22 @@ To create yours: 5. Copy the **Application ID**. 6. Paste only that numeric ID into: -\`\`\`text -.modernization_tool\WowPresence\discord_application_id -\`\`\` +```text +WowPresence\discord_application_id +``` Example: -\`\`\`text +```text 123456789012345678 -\`\`\` +``` -No Discord Client Secret, bot token, or bot account is used or required. +No Discord Client Secret, bot token or bot account is used or required. -If the file is missing or invalid, WowPresence leaves Rich Presence disabled and records the reason in \`WowPresence.log\`. +If `discord_application_id` is missing or invalid, Rich Presence remains disabled and the reason is written to `WowPresence\WowPresence.log`. ## ✨ Features -- Discord Rich Presence for WoW 1.12.1. - Character name and guild display. - Level and class display. - Race and faction display. @@ -81,8 +81,9 @@ If the file is missing or invalid, WowPresence leaves Rich Presence disabled and - Automatic companion startup. - Exact WoW process tracking. - Read-only game-memory sampling. -- Native Discord IPC with no bot or Client Secret required. +- Native Discord IPC. - Optional privacy flags for individual character fields. +- No bot, Client Secret or external service required. ## 🔧 Compatibility @@ -106,40 +107,36 @@ Other modified 1.12.1 clients should be considered **untested until verified**. ## ⚙️ How it works -\`\`\`text +```text WoW └─ WowPresence.dll ├─ read-only character sampling - ├─ local JSON status + ├─ WowPresence\discord_wow_status.json └─ starts WowPresence.exe └─ Discord local IPC -\`\`\` +``` The DLL does not communicate directly with Discord. The companion executable does not read or write WoW memory. -Character status is written to: +Runtime files are kept together in: -\`\`\`text -.modernization_tool\WowPresence\discord_wow_status.json -\`\`\` - -The companion log is written to: - -\`\`\`text -.modernization_tool\WowPresence\WowPresence.log -\`\`\` - -These runtime files are created automatically. +```text +\WowPresence\ +├─ discord_application_id +├─ discord_broadcast_flags +├─ discord_wow_status.json +└─ WowPresence.log +``` ## 🔒 Privacy flags The included file: -\`\`\`text -.modernization_tool\WowPresence\discord_broadcast_flags -\`\`\` +```text +WowPresence\discord_broadcast_flags +``` controls which character details are published to Discord. @@ -153,7 +150,7 @@ controls which character details are published to Discord. | 32 | Zone | | 63 | All fields | -The release package uses \`63\` by default. +The release package uses `63` by default. If the file is deleted, WowPresence also defaults to all supported fields. @@ -171,26 +168,26 @@ WowPresence targets **32-bit x86 Windows**, matching WoW 1.12.1. With an x86 MSVC developer environment: -\`\`\`bat +```bat cl /nologo /O2 /MT /W3 /LD src\WowPresence.c /Fe:WowPresence.dll /link /INCREMENTAL:NO cl /nologo /O2 /MT /W3 src\WowPresenceLauncher.c user32.lib /Fe:WowPresence.exe /link /SUBSYSTEM:WINDOWS /INCREMENTAL:NO -\`\`\` +``` GitHub Actions builds: -- \`WowPresence.dll\` -- \`WowPresence.exe\` -- \`WowPresence.zip\` +- `WowPresence.dll` +- `WowPresence.exe` +- `WowPresence.zip` -The ZIP also includes the ready-to-edit configuration files. +The ZIP includes the ready-to-edit `WowPresence` configuration folder. -Tags matching \`v*\` can publish these files as a GitHub Release. +Tags matching `v*` can publish these files as a GitHub Release. ## 📜 Project identity & licensing WowPresence is an **independent community project**. -Compatibility with **World of Warcraft**, **Discord**, **OctoWoW**, **VanillaFixes**, **IchaLaunch**, or other referenced projects does not imply affiliation, endorsement, sponsorship, or ownership by their respective rights holders or maintainers. +Compatibility with **World of Warcraft**, **Discord**, **OctoWoW**, **VanillaFixes**, **IchaLaunch**, or other referenced projects does not imply affiliation, endorsement, sponsorship or ownership by their respective rights holders or maintainers. **World of Warcraft**, **Warcraft**, **Blizzard Entertainment**, **Discord**, and related names and marks remain the property of their respective rights holders. -- 2.52.0 From 5a8fd856b910e0faac9c3f296018c3b49e59c19f Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 16:03:45 +0200 Subject: [PATCH 16/35] Prepare WowPresence v1.0 release --- .github/workflows/build.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c9ba07..39fea7b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,6 +72,22 @@ jobs: dist/WowPresence.zip if-no-files-found: error + - name: Create GitHub Release v1.0 + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.event.head_commit.message == 'release v1.0' }} + uses: softprops/action-gh-release@v3 + with: + tag_name: v1.0 + target_commitish: ${{ github.sha }} + files: | + dist/WowPresence.dll + dist/WowPresence.exe + dist/WowPresence.zip + fail_on_unmatched_files: true + name: "WowPresence v1.0" + body_path: RELEASE_NOTES.md + generate_release_notes: true + make_latest: true + - name: Create GitHub Release if: ${{ startsWith(github.ref, 'refs/tags/') }} uses: softprops/action-gh-release@v3 -- 2.52.0 From bbe8fe2b83c3e4a71e813fd9c8932bf703001ac8 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 16:03:55 +0200 Subject: [PATCH 17/35] release v1.0 --- RELEASE_NOTES.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 RELEASE_NOTES.md diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..fba6e82 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,25 @@ +# WowPresence v1.0 + +First standalone release of WowPresence. + +## Included + +- WowPresence.dll +- WowPresence.exe +- WowPresence.zip ready-to-install package + +## Highlights + +- Native Discord Rich Presence for WoW 1.12.1 build 5875 compatible clients +- Character name, guild, level, class, race, faction and zone display +- Stable elapsed-session timer across character changes +- Read-only game-memory sampling +- Separate native Discord IPC companion +- Configurable Discord Application ID through `WowPresence\discord_application_id` +- Privacy flags through `WowPresence\discord_broadcast_flags` +- Standalone `WowPresence\` runtime folder +- Automatic companion startup and exact WoW process tracking + +## Installation + +Extract `WowPresence.zip` into the WoW folder, edit `WowPresence\discord_application_id`, add `WowPresence.dll` to VanillaFixes `dlls.txt`, then launch WoW normally. -- 2.52.0 From 94490807885c9a69f3ef36392ab882613d806e02 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 16:13:53 +0200 Subject: [PATCH 18/35] Support Modernization Tool data directory --- src/WowPresence.c | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/WowPresence.c b/src/WowPresence.c index b10cceb..e0afa75 100644 --- a/src/WowPresence.c +++ b/src/WowPresence.c @@ -3,8 +3,8 @@ * Read-only WoW 1.12.1 (build 5875) status sampler for WowPresence. * * Runtime contract: - * \WowPresence\discord_wow_status.json - * \WowPresence\discord_broadcast_flags + * standalone: \WowPresence\... + * Modernization Tool: \.modernization_tool\WowPresence\... * * Discord IPC is intentionally out of process. After the first sample, this * DLL starts WowPresence.exe from its worker thread and passes the exact @@ -541,11 +541,34 @@ static int game_folder(char *output, size_t output_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_folder(char *output, size_t output_size) { - char root[MAX_PATH]; + char root[MAX_PATH], managed[MAX_PATH]; int count; if (!game_folder(root, sizeof(root))) return 0; + /* Modernization Tool creates this directory before WoW starts. Prefer it + * when present so the same binaries can use the tool-managed location. */ + count = _snprintf( + managed, + sizeof(managed), + "%s\\.modernization_tool\\WowPresence", + root + ); + managed[sizeof(managed) - 1] = 0; + if (count >= 0 && (size_t)count < sizeof(managed) && directory_exists(managed)) { + if (strlen(managed) + 1u > output_size) return 0; + lstrcpynA(output, managed, (int)output_size); + return 1; + } + + /* Standalone installs keep their data in \\WowPresence. */ count = _snprintf(output, output_size, "%s\\WowPresence", root); if (output_size) output[output_size - 1] = 0; if (count < 0 || (size_t)count >= output_size) return 0; -- 2.52.0 From 2a10763dac7b5be11596b84d91e40ec71ce90eec Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 16:13:59 +0200 Subject: [PATCH 19/35] Support Modernization Tool data directory --- src/WowPresenceLauncher.c | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/WowPresenceLauncher.c b/src/WowPresenceLauncher.c index 05d97fa..fe714b9 100644 --- a/src/WowPresenceLauncher.c +++ b/src/WowPresenceLauncher.c @@ -3,7 +3,7 @@ * Invisible companion process for WowPresence. * * - is started automatically by WowPresence.dll after WoW is running - * - reads WowPresence\discord_wow_status.json + * - reads the standalone WowPresence folder or the Modernization Tool managed folder * - publishes Discord Rich Presence over the local Discord IPC named pipe * - exits when WoW_Modernized.exe exits */ @@ -54,9 +54,30 @@ static int own_directory(char *out, size_t 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]; + 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; @@ -140,7 +161,7 @@ static int load_application_id(char *out, size_t out_size) { 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 WowPresence\\discord_application_id."); + log_line("discord_application_id is missing. Add your Discord Application ID to the active WowPresence data folder."); return 0; } -- 2.52.0 From d0aff5e95af797a3115f81d1b860dc338a42ce31 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 16:14:15 +0200 Subject: [PATCH 20/35] Document Modernization Tool data path --- README.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 45ce4fe..eaea751 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,14 @@ After extraction, the relevant files should look like this: The runtime JSON and log files are created automatically inside the same `WowPresence` folder. +When WowPresence is installed through **Modernization Tool**, the same binaries automatically use: + +```text +\.modernization_tool\WowPresence\ +``` + +instead. No separate build is required. + ## 🛰️ Discord Application ID WowPresence intentionally does **not** include a server-specific Discord Application ID. @@ -120,7 +128,9 @@ The DLL does not communicate directly with Discord. The companion executable does not read or write WoW memory. -Runtime files are kept together in: +Runtime files are kept together in one of these locations: + +**Standalone installation:** ```text \WowPresence\ @@ -130,6 +140,18 @@ Runtime files are kept together in: └─ WowPresence.log ``` +**Installed through Modernization Tool:** + +```text +\.modernization_tool\WowPresence\ +├─ discord_application_id +├─ discord_broadcast_flags +├─ discord_wow_status.json +└─ WowPresence.log +``` + +If the Modernization Tool managed folder exists, WowPresence automatically prefers it. Otherwise it uses the standalone `WowPresence` folder. + ## 🔒 Privacy flags The included file: -- 2.52.0 From aa9412d7c2934ce929f9d0dc03a6e12c273b15f8 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 16:18:19 +0200 Subject: [PATCH 21/35] Prepare WowPresence v1.1 release --- .github/workflows/build.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39fea7b..03c7c05 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,18 +72,18 @@ jobs: dist/WowPresence.zip if-no-files-found: error - - name: Create GitHub Release v1.0 - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.event.head_commit.message == 'release v1.0' }} + - name: Create GitHub Release v1.1 + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.event.head_commit.message == 'release v1.1' }} uses: softprops/action-gh-release@v3 with: - tag_name: v1.0 + tag_name: v1.1 target_commitish: ${{ github.sha }} files: | dist/WowPresence.dll dist/WowPresence.exe dist/WowPresence.zip fail_on_unmatched_files: true - name: "WowPresence v1.0" + name: "WowPresence v1.1" body_path: RELEASE_NOTES.md generate_release_notes: true make_latest: true -- 2.52.0 From 750fe8a2e516b6a1f466230c3848b4fff8de8648 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 16:18:30 +0200 Subject: [PATCH 22/35] release v1.1 --- RELEASE_NOTES.md | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fba6e82..c9cd862 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,25 +1,19 @@ -# WowPresence v1.0 +# WowPresence v1.1 -First standalone release of WowPresence. +## Changes + +- Added automatic support for Modernization Tool installations. +- Standalone installs continue to use `WowPresence\` in the WoW folder. +- When `.modernization_tool\WowPresence\` already exists, the same binaries automatically use that managed data directory instead. +- No separate Modernization Tool build of WowPresence is required. +- Existing `discord_application_id` and `discord_broadcast_flags` files remain user-managed. ## Included -- WowPresence.dll -- WowPresence.exe -- WowPresence.zip ready-to-install package +- `WowPresence.dll` +- `WowPresence.exe` +- `WowPresence.zip` ready-to-install standalone package -## Highlights - -- Native Discord Rich Presence for WoW 1.12.1 build 5875 compatible clients -- Character name, guild, level, class, race, faction and zone display -- Stable elapsed-session timer across character changes -- Read-only game-memory sampling -- Separate native Discord IPC companion -- Configurable Discord Application ID through `WowPresence\discord_application_id` -- Privacy flags through `WowPresence\discord_broadcast_flags` -- Standalone `WowPresence\` runtime folder -- Automatic companion startup and exact WoW process tracking - -## Installation +## Standalone installation Extract `WowPresence.zip` into the WoW folder, edit `WowPresence\discord_application_id`, add `WowPresence.dll` to VanillaFixes `dlls.txt`, then launch WoW normally. -- 2.52.0 From f261c3be298d56bc025671e22a348555157f94b3 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 16:50:09 +0200 Subject: [PATCH 23/35] Resolve zones through AreaTable --- src/WowPresence.c | 163 +++++++++++++++++++++++++++++++++------------- 1 file changed, 119 insertions(+), 44 deletions(-) diff --git a/src/WowPresence.c b/src/WowPresence.c index e0afa75..91431ec 100644 --- a/src/WowPresence.c +++ b/src/WowPresence.c @@ -11,8 +11,10 @@ * WoW process id. No process launch is performed from DllMain. * * This implementation is organized independently around a small memory-view - * layer and a snapshot serializer. Client addresses are the community-known - * WoW 1.12.1 / build 5875 values used for compatibility with the target client. + * layer and a snapshot serializer. Zone names are resolved from the player's + * current AreaTable ID instead of guessing among raw text addresses. Client + * addresses are community-documented WoW 1.12.1 / build 5875 compatibility + * values used by the target client. */ #define WIN32_LEAN_AND_MEAN #include @@ -24,6 +26,16 @@ #define CLIENT_IMAGE_BASE 0x00400000u #define CLIENT_OBJECT_MANAGER_VA 0x00B41414u +#define CLIENT_IN_WORLD_VA 0x00B4B424u +#define CLIENT_PLAYER_AREA_ID_VA 0x00B4E314u +#define AREATABLE_RECORDS_VA 0x00C0E048u +#define AREATABLE_COUNT_VA 0x00C0E04Cu +#define LOCALE_INDEX_VA 0x00C0E080u +#define AREATABLE_PARENT_ID_OFF 0x08u +#define AREATABLE_NAMES_OFF 0x2Cu +#define AREATABLE_LOCALE_SLOTS 9u +#define AREATABLE_MAX_RECORDS 65535u + #define OM_FIRST_OBJECT_OFF 0xACu #define OM_LOCAL_GUID_OFF 0xC0u #define OBJECT_NEXT_OFF 0x3Cu @@ -69,11 +81,6 @@ static const uintptr_t kPlayerNameLocations[] = { 0x00C27FC8u, 0x00C27D88u, 0x00C27FD8u, 0 }; -static const uintptr_t kZoneNameLocations[] = { - 0x00B4B404u, 0x00B4B424u, 0x00CE06D0u, - 0x00CE06F8u, 0x00B4B3C8u, 0 -}; - typedef struct MemoryView { HANDLE process; uintptr_t module_base; @@ -233,45 +240,114 @@ static int valid_display_text(const char *text, size_t max_length) { return 1; } -static int looks_like_internal_zone_code(const char *text) { - size_t i, length; - int has_letter = 0; - int has_digit = 0; - - if (!text) return 0; - length = strlen(text); - - /* Some 1.12-compatible clients expose compact internal area tokens - * (for example "H32D") at one of the candidate zone addresses. These - * are identifiers, not user-facing zone names. Keep the rule narrow: - * short mixed letter/digit strings with no separators are rejected, - * while legitimate names such as "Area 52" remain valid. */ - if (length < 2u || length > 8u) return 0; - - for (i = 0u; i < length; ++i) { - char ch = text[i]; - if (ascii_letter(ch)) { - has_letter = 1; - } else if (ch >= '0' && ch <= '9') { - has_digit = 1; - } else { - return 0; - } - } - - return has_letter && has_digit; -} - -static int valid_zone_name(const char *text) { - if (!valid_display_text(text, ZONE_LIMIT)) return 0; - return !looks_like_internal_zone_code(text); -} - static int valid_guild_name(const char *text) { if (!valid_display_text(text, GUILD_LIMIT)) return 0; return _stricmp(text, "none") != 0; } +static int valid_area_name(const char *text) { + size_t i, length; + int has_letter = 0; + if (!text) return 0; + length = strlen(text); + if (length < 2u || length > ZONE_LIMIT) return 0; + for (i = 0u; i < length; ++i) { + if (ascii_letter(text[i])) has_letter = 1; + } + return has_letter; +} + +static int client_u32(const MemoryView *view, uintptr_t vanilla_va, uint32_t *value) { + uintptr_t address = client_address(view, vanilla_va); + return address && memory_u32(view, address, value); +} + +static int area_record(const MemoryView *view, uint32_t area_id, uint32_t *record) { + uint32_t count = 0, records = 0, candidate = 0; + uintptr_t slot; + if (!view || !record || !area_id) return 0; + if (!client_u32(view, AREATABLE_COUNT_VA, &count) || + !count || count > AREATABLE_MAX_RECORDS || area_id > count) { + return 0; + } + if (!client_u32(view, AREATABLE_RECORDS_VA, &records) || + !user_address((uintptr_t)records)) { + return 0; + } + + slot = (uintptr_t)records + (uintptr_t)area_id * sizeof(uint32_t); + if (!memory_u32(view, slot, &candidate) || + !user_address((uintptr_t)candidate)) { + return 0; + } + + *record = candidate; + return 1; +} + +static int area_name_from_record(const MemoryView *view, uint32_t record, + char *output, size_t output_size) { + uint32_t locale = 0; + unsigned pass; + if (!view || !user_address((uintptr_t)record) || !output || output_size < 2u) + return 0; + output[0] = 0; + + if (!client_u32(view, LOCALE_INDEX_VA, &locale) || + locale >= AREATABLE_LOCALE_SLOTS) { + locale = 0; + } + + /* Try the active client locale first. If that string is unavailable or + * not representable by the current ASCII JSON path, fall back to another + * populated locale slot rather than publishing an internal map token. */ + for (pass = 0u; pass < AREATABLE_LOCALE_SLOTS; ++pass) { + unsigned index = pass == 0u ? (unsigned)locale : pass - 1u; + uint32_t name_ptr = 0; + uintptr_t pointer_slot; + + if (pass > 0u && index >= locale) ++index; + if (index >= AREATABLE_LOCALE_SLOTS) continue; + + pointer_slot = (uintptr_t)record + AREATABLE_NAMES_OFF + + (uintptr_t)index * sizeof(uint32_t); + if (!memory_u32(view, pointer_slot, &name_ptr) || + !user_address((uintptr_t)name_ptr)) { + continue; + } + if (memory_ascii(view, (uintptr_t)name_ptr, output, output_size, ZONE_LIMIT) && + valid_area_name(output)) { + return 1; + } + output[0] = 0; + } + return 0; +} + +static int read_player_zone(const MemoryView *view, char *output, size_t output_size) { + uint32_t raw_area = 0, area_id, record = 0, parent_id = 0, parent_record = 0; + if (!view || !output || output_size < 2u) return 0; + output[0] = 0; + + if (!client_u32(view, CLIENT_PLAYER_AREA_ID_VA, &raw_area)) return 0; + area_id = raw_area & 0xFFFFu; + if (!area_id || !area_record(view, area_id, &record)) return 0; + + /* GetRealZoneText resolves sub-areas to their enclosing AreaTable zone. + * Mirror that behavior when a valid parent row is available. */ + if (memory_u32(view, (uintptr_t)record + AREATABLE_PARENT_ID_OFF, &parent_id) && + parent_id && area_record(view, parent_id, &parent_record)) { + record = parent_record; + } + + return area_name_from_record(view, record, output, output_size); +} + +static int client_is_in_world(const MemoryView *view) { + uint32_t value = 0; + return client_u32(view, CLIENT_IN_WORLD_VA, &value) && (value & 0xFFu) != 0u; +} + static int text_from_location(const MemoryView *view, uintptr_t vanilla_va, char *output, size_t output_size, size_t max_chars, int (*validator)(const char *)) { @@ -510,9 +586,8 @@ static void collect_character_snapshot(CharacterSnapshot *snapshot) { have_name = first_text_match(&view, kPlayerNameLocations, snapshot->name, sizeof(snapshot->name), NAME_LIMIT, valid_player_name); - have_zone = first_text_match(&view, kZoneNameLocations, snapshot->zone, - sizeof(snapshot->zone), ZONE_LIMIT, valid_zone_name); - snapshot->in_world = have_name && have_zone; + have_zone = read_player_zone(&view, snapshot->zone, sizeof(snapshot->zone)); + snapshot->in_world = client_is_in_world(&view) && have_name && have_zone; now = GetTickCount(); if (!snapshot->in_world) { -- 2.52.0 From 0994fe4e08b03ec17b09d09eb51a2e7c2b0b2db1 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 16:50:43 +0200 Subject: [PATCH 24/35] Document AreaTable zone resolution --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eaea751..d0d783b 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ If `discord_application_id` is missing or invalid, Rich Presence remains disable - Character name and guild display. - Level and class display. - Race and faction display. -- Current zone display. +- Current zone display resolved from the client AreaTable instead of heuristic text-address scanning. - Stable elapsed-session timer across character changes. - Automatic companion startup. - Exact WoW process tracking. @@ -119,11 +119,14 @@ Other modified 1.12.1 clients should be considered **untested until verified**. WoW └─ WowPresence.dll ├─ read-only character sampling + ├─ Area ID → AreaTable zone resolution ├─ WowPresence\discord_wow_status.json └─ starts WowPresence.exe └─ Discord local IPC ``` +Zone names are resolved from the player's current AreaTable ID, matching the client's own zone data and avoiding internal map tokens such as `H32D` or `HLVA`. + The DLL does not communicate directly with Discord. The companion executable does not read or write WoW memory. -- 2.52.0 From 3c76de9bdbcb19a45543bd09b03bbd75a2d65d8f Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 16:50:55 +0200 Subject: [PATCH 25/35] Document ClassicAPI technical reference --- THIRD_PARTY_NOTICES.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fcdac5a..6b62136 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -16,6 +16,15 @@ The current WowPresence implementation is maintained separately and has been ind At the historical IchaLaunch revision reviewed during development, no explicit LICENSE, LICENSE.md, or COPYING file was present. This notice therefore records provenance and credit; it should not be interpreted as granting rights on behalf of the IchaLaunch author. +## ClassicAPI technical reference + +AreaTable and current-player-area client layout details were cross-checked against: + +- Project: ClassicAPI +- Repository: https://github.com/brues-code/ClassicAPI + +ClassicAPI is used as a technical/reference source for documented WoW 1.12.1 client structures. WowPresence does not require, load, or call ClassicAPI. + ## World of Warcraft client compatibility data WowPresence uses client-layout information and memory offsets associated with World of Warcraft 1.12.1 build 5875. Such compatibility information has been documented in multiple community projects over the years. -- 2.52.0 From 8048e804f6629c75cc3b1d81f835aaa1c1f8a423 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 16:51:05 +0200 Subject: [PATCH 26/35] Prepare WowPresence v1.2 release --- .github/workflows/build.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 03c7c05..7016ceb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,18 +72,18 @@ jobs: dist/WowPresence.zip if-no-files-found: error - - name: Create GitHub Release v1.1 - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.event.head_commit.message == 'release v1.1' }} + - name: Create GitHub Release v1.2 + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.event.head_commit.message == 'release v1.2' }} uses: softprops/action-gh-release@v3 with: - tag_name: v1.1 + tag_name: v1.2 target_commitish: ${{ github.sha }} files: | dist/WowPresence.dll dist/WowPresence.exe dist/WowPresence.zip fail_on_unmatched_files: true - name: "WowPresence v1.1" + name: "WowPresence v1.2" body_path: RELEASE_NOTES.md generate_release_notes: true make_latest: true -- 2.52.0 From e393a7999ba8eb160568313b60e797c7bbc6c47d Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 16:51:22 +0200 Subject: [PATCH 27/35] release v1.2 --- RELEASE_NOTES.md | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c9cd862..4c6f319 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,19 +1,16 @@ -# WowPresence v1.1 +# WowPresence v1.2 ## Changes -- Added automatic support for Modernization Tool installations. -- Standalone installs continue to use `WowPresence\` in the WoW folder. -- When `.modernization_tool\WowPresence\` already exists, the same binaries automatically use that managed data directory instead. -- No separate Modernization Tool build of WowPresence is required. -- Existing `discord_application_id` and `discord_broadcast_flags` files remain user-managed. +- Reworked zone detection to use the player's real AreaTable ID. +- Zone names are now resolved from `AreaTable.dbc` instead of selecting the first plausible string from several client addresses. +- Prevents internal map/area tokens such as `H32D` and `HLVA` from being published as Discord zone names. +- Uses the client's active localized AreaTable name when available, with a safe populated-locale fallback. +- Keeps WowPresence fully standalone; ClassicAPI is not required. +- Modernization Tool installations continue to use `.modernization_tool\WowPresence\` automatically. ## Included - `WowPresence.dll` - `WowPresence.exe` - `WowPresence.zip` ready-to-install standalone package - -## Standalone installation - -Extract `WowPresence.zip` into the WoW folder, edit `WowPresence\discord_application_id`, add `WowPresence.dll` to VanillaFixes `dlls.txt`, then launch WoW normally. -- 2.52.0 From 4576605dce59702d8f90d542e7db3dae4789e1ed Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 20:10:15 +0200 Subject: [PATCH 28/35] Document OctoWoW Discord Application ID --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d0d783b..d4aae9c 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,16 @@ instead. No separate build is required. ## 🛰️ Discord Application ID -WowPresence intentionally does **not** include a server-specific Discord Application ID. +For **OctoWoW**, you can use the preconfigured Discord Application ID: + +```text +1544072796098011176 +``` + +This is the same Application ID automatically configured by **Modernization Tool**. + +If you want to use your own Discord application instead: -To create yours: 1. Open the **Discord Developer Portal**: https://discord.com/developers/applications 2. Create a **New Application**. -- 2.52.0 From 5f7f27a81f549df4b4b762611f6aabc9d76697f8 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 20:35:33 +0200 Subject: [PATCH 29/35] Test resilient in-world detection fallback --- src/WowPresence.c | 131 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 106 insertions(+), 25 deletions(-) diff --git a/src/WowPresence.c b/src/WowPresence.c index 91431ec..3677a10 100644 --- a/src/WowPresence.c +++ b/src/WowPresence.c @@ -86,8 +86,25 @@ typedef struct MemoryView { uintptr_t module_base; } MemoryView; +typedef enum PlayerReadResult { + PLAYER_READ_NOT_ATTEMPTED = 0, + PLAYER_READ_OK, + PLAYER_READ_NO_MANAGER, + PLAYER_READ_NO_GUID, + PLAYER_READ_NO_FIRST_OBJECT, + PLAYER_READ_LIST_CHANGED, + PLAYER_READ_OBJECT_READ_FAILED, + PLAYER_READ_BAD_DESCRIPTORS, + PLAYER_READ_BAD_PLAYER_DATA, + PLAYER_READ_CHAIN_BROKEN, + PLAYER_READ_NOT_FOUND +} PlayerReadResult; + typedef struct CharacterSnapshot { int in_world; + int raw_in_world; + int player_confirmed; + PlayerReadResult player_read_result; char name[NAME_LIMIT + 1]; char zone[ZONE_LIMIT + 1]; char guild[GUILD_LIMIT + 1]; @@ -531,15 +548,36 @@ static void read_player_guild(const MemoryView *view, uintptr_t player_object, } } -static int read_local_player(const MemoryView *view, CharacterSnapshot *snapshot) { +static const char *player_read_error(PlayerReadResult result) { + switch (result) { + case PLAYER_READ_OK: return ""; + case PLAYER_READ_NO_MANAGER: return "object_manager"; + case PLAYER_READ_NO_GUID: return "local_guid"; + case PLAYER_READ_NO_FIRST_OBJECT: return "first_object"; + case PLAYER_READ_LIST_CHANGED: return "object_list_changed"; + case PLAYER_READ_OBJECT_READ_FAILED: return "object_read"; + case PLAYER_READ_BAD_DESCRIPTORS: return "descriptors"; + case PLAYER_READ_BAD_PLAYER_DATA: return "player_data"; + case PLAYER_READ_CHAIN_BROKEN: return "object_chain"; + case PLAYER_READ_NOT_FOUND: return "player_not_found"; + case PLAYER_READ_NOT_ATTEMPTED: + default: + return "not_attempted"; + } +} + +static PlayerReadResult read_local_player(const MemoryView *view, CharacterSnapshot *snapshot) { uint32_t manager = 0, current = 0, initial_first = 0; uint64_t wanted_guid = 0; unsigned visited = 0u; - if (!view || !snapshot || !object_manager_pointer(view, &manager)) return 0; - if (!stable_local_guid(view, manager, &wanted_guid)) return 0; + if (!view || !snapshot || !object_manager_pointer(view, &manager)) + return PLAYER_READ_NO_MANAGER; + if (!stable_local_guid(view, manager, &wanted_guid)) + return PLAYER_READ_NO_GUID; if (!memory_u32(view, (uintptr_t)manager + OM_FIRST_OBJECT_OFF, ¤t) || - !user_address((uintptr_t)current)) return 0; + !user_address((uintptr_t)current)) + return PLAYER_READ_NO_FIRST_OBJECT; initial_first = current; while (user_address((uintptr_t)current) && !(current & 1u) && visited++ < MAX_OBJECT_VISITS) { @@ -547,33 +585,45 @@ static int read_local_player(const MemoryView *view, CharacterSnapshot *snapshot uint64_t object_guid = 0; if (!memory_u32(view, (uintptr_t)manager + OM_FIRST_OBJECT_OFF, ¤t_first) || - current_first != initial_first) return 0; - if (!memory_u32(view, (uintptr_t)current + OBJECT_TYPE_OFF, &object_type)) return 0; + current_first != initial_first) + return PLAYER_READ_LIST_CHANGED; + if (!memory_u32(view, (uintptr_t)current + OBJECT_TYPE_OFF, &object_type)) + return PLAYER_READ_OBJECT_READ_FAILED; if (object_type == PLAYER_OBJECT_TYPE && memory_u64(view, (uintptr_t)current + OBJECT_GUID_OFF, &object_guid) && - object_guid == wanted_guid && - memory_u32(view, (uintptr_t)current + OBJECT_DESCRIPTORS_OFF, &descriptors) && - user_address((uintptr_t)descriptors)) { + object_guid == wanted_guid) { uint32_t level = 0, bytes0 = 0; - if (memory_u32(view, (uintptr_t)descriptors + UNIT_LEVEL_OFF, &level) && - level >= 1u && level <= 80u) { - snapshot->level = level; - } - if (memory_u32(view, (uintptr_t)descriptors + UNIT_BYTES0_OFF, &bytes0)) { + int have_level, have_identity; + + if (!memory_u32(view, (uintptr_t)current + OBJECT_DESCRIPTORS_OFF, &descriptors) || + !user_address((uintptr_t)descriptors)) + return PLAYER_READ_BAD_DESCRIPTORS; + + have_level = memory_u32(view, (uintptr_t)descriptors + UNIT_LEVEL_OFF, &level) && + level >= 1u && level <= 80u; + have_identity = memory_u32(view, (uintptr_t)descriptors + UNIT_BYTES0_OFF, &bytes0); + + if (have_level) snapshot->level = level; + if (have_identity) { snapshot->race_id = bytes0 & 0xFFu; snapshot->class_id = (bytes0 >> 8) & 0xFFu; } + read_player_guild(view, (uintptr_t)current, descriptors, snapshot->guild, sizeof(snapshot->guild)); - return 1; + + if (!have_level || !have_identity || !snapshot->race_id || !snapshot->class_id) + return PLAYER_READ_BAD_PLAYER_DATA; + return PLAYER_READ_OK; } if (!memory_u32(view, (uintptr_t)current + OBJECT_NEXT_OFF, &next) || - next == current || !user_address((uintptr_t)next)) return 0; + next == current || !user_address((uintptr_t)next)) + return PLAYER_READ_CHAIN_BROKEN; current = next; } - return 0; + return PLAYER_READ_NOT_FOUND; } static void collect_character_snapshot(CharacterSnapshot *snapshot) { @@ -582,22 +632,40 @@ static void collect_character_snapshot(CharacterSnapshot *snapshot) { int have_name, have_zone; if (!snapshot) return; memset(snapshot, 0, sizeof(*snapshot)); + snapshot->player_read_result = PLAYER_READ_NOT_ATTEMPTED; memory_view_init(&view); have_name = first_text_match(&view, kPlayerNameLocations, snapshot->name, sizeof(snapshot->name), NAME_LIMIT, valid_player_name); have_zone = read_player_zone(&view, snapshot->zone, sizeof(snapshot->zone)); - snapshot->in_world = client_is_in_world(&view) && have_name && have_zone; + snapshot->raw_in_world = client_is_in_world(&view); - now = GetTickCount(); - if (!snapshot->in_world) { + /* Name + zone are used only as a candidate signal. The legacy in-world + * byte remains the primary signal, but a fully validated local-player + * object can confirm world state when that byte is unreliable on a + * compatible client. */ + if (!have_name || !have_zone) { gWorldReadySince = 0; return; } + now = GetTickCount(); if (!gWorldReadySince) gWorldReadySince = now ? now : 1u; - if ((DWORD)(now - gWorldReadySince) < WORLD_SETTLE_MS) return; - read_local_player(&view, snapshot); + + /* Preserve the old behavior during the short settle window when the + * client flag is healthy. Clients with a bad flag remain hidden until + * the local-player object has been positively validated. */ + if ((DWORD)(now - gWorldReadySince) < WORLD_SETTLE_MS) { + snapshot->in_world = snapshot->raw_in_world; + return; + } + + snapshot->player_read_result = read_local_player(&view, snapshot); + snapshot->player_confirmed = snapshot->player_read_result == PLAYER_READ_OK; + + /* A valid player object is a conservative fallback for clients where + * CLIENT_IN_WORLD_VA does not mirror the expected vanilla value. */ + snapshot->in_world = snapshot->raw_in_world || snapshot->player_confirmed; } static int game_folder(char *output, size_t output_size) { @@ -735,7 +803,8 @@ static int replace_text_file_atomically(const char *file_name, const char *conte static void publish_fault_snapshot(void) { replace_text_file_atomically( "discord_wow_status.json", - "{\"v\":1,\"ts\":0,\"ok\":false,\"in_world\":false,\"name\":\"\"," + "{\"v\":1,\"ts\":0,\"ok\":false,\"in_world\":false," + "\"raw_in_world\":false,\"player_confirmed\":false,\"name\":\"\"," "\"zone\":\"\",\"level\":0,\"faction\":\"\",\"class\":\"\"," "\"guild\":\"\",\"race\":\"\",\"build\":5875,\"err\":\"fault\"}"); } @@ -747,7 +816,7 @@ static void publish_character_snapshot(void) { char safe_zone[ZONE_LIMIT * 2u + 8u]; char safe_guild[GUILD_LIMIT * 2u + 8u]; char json[JSON_BUFFER_SIZE]; - const char *race_text, *class_text, *faction_text; + const char *race_text, *class_text, *faction_text, *error_text = ""; collect_character_snapshot(&snapshot); mask = load_share_mask(); @@ -761,15 +830,27 @@ static void publish_character_snapshot(void) { json_quote_text((mask & SHARE_ZONE) ? snapshot.zone : "", safe_zone, sizeof(safe_zone)); json_quote_text((mask & SHARE_GUILD) ? snapshot.guild : "", safe_guild, sizeof(safe_guild)); + if (!snapshot.in_world) { + if (!snapshot.name[0]) error_text = "name"; + else if (!snapshot.zone[0]) error_text = "zone"; + else if (snapshot.player_read_result == PLAYER_READ_NOT_ATTEMPTED) + error_text = snapshot.raw_in_world ? "settling" : "in_world_flag"; + else + error_text = player_read_error(snapshot.player_read_result); + } + _snprintf( json, sizeof(json), "{\"v\":1,\"ts\":%ld,\"ok\":%s,\"in_world\":%s," + "\"raw_in_world\":%s,\"player_confirmed\":%s," "\"name\":\"%s\",\"zone\":\"%s\",\"level\":%u," "\"faction\":\"%s\",\"class\":\"%s\",\"guild\":\"%s\"," "\"race\":\"%s\",\"build\":5875,\"err\":\"%s\"}", (long)time(NULL), snapshot.in_world ? "true" : "false", snapshot.in_world ? "true" : "false", + snapshot.raw_in_world ? "true" : "false", + snapshot.player_confirmed ? "true" : "false", safe_name, safe_zone, (snapshot.in_world && (mask & SHARE_LEVEL)) ? snapshot.level : 0u, @@ -777,7 +858,7 @@ static void publish_character_snapshot(void) { class_text, safe_guild, race_text, - snapshot.in_world ? "" : "offsets"); + error_text); json[sizeof(json) - 1] = 0; replace_text_file_atomically("discord_wow_status.json", json); } -- 2.52.0 From 15e723c7eb00973616a3b0d9e9ef35667945d244 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 21:05:45 +0200 Subject: [PATCH 30/35] Expose player read failures in partial snapshots --- src/WowPresence.c | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/WowPresence.c b/src/WowPresence.c index 3677a10..8c57d38 100644 --- a/src/WowPresence.c +++ b/src/WowPresence.c @@ -830,13 +830,18 @@ static void publish_character_snapshot(void) { json_quote_text((mask & SHARE_ZONE) ? snapshot.zone : "", safe_zone, sizeof(safe_zone)); json_quote_text((mask & SHARE_GUILD) ? snapshot.guild : "", safe_guild, sizeof(safe_guild)); - if (!snapshot.in_world) { - if (!snapshot.name[0]) error_text = "name"; - else if (!snapshot.zone[0]) error_text = "zone"; - else if (snapshot.player_read_result == PLAYER_READ_NOT_ATTEMPTED) + if (!snapshot.name[0]) { + error_text = "name"; + } else if (!snapshot.zone[0]) { + error_text = "zone"; + } else if (snapshot.player_read_result == PLAYER_READ_NOT_ATTEMPTED) { + if (!snapshot.in_world) error_text = snapshot.raw_in_world ? "settling" : "in_world_flag"; - else - error_text = player_read_error(snapshot.player_read_result); + } else if (snapshot.player_read_result != PLAYER_READ_OK) { + /* Keep player-object diagnostics visible even when the legacy + * in-world flag is true. This makes partial snapshots diagnosable + * instead of reporting an empty error string. */ + error_text = player_read_error(snapshot.player_read_result); } _snprintf( -- 2.52.0 From 1614bc4440b71a3a8386cbae88bbfd39d68ab4b3 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 21:17:26 +0200 Subject: [PATCH 31/35] Add validated first-object fallback scan --- src/WowPresence.c | 92 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 85 insertions(+), 7 deletions(-) diff --git a/src/WowPresence.c b/src/WowPresence.c index 8c57d38..1b288f7 100644 --- a/src/WowPresence.c +++ b/src/WowPresence.c @@ -37,6 +37,8 @@ #define AREATABLE_MAX_RECORDS 65535u #define OM_FIRST_OBJECT_OFF 0xACu +#define OM_FIRST_OBJECT_SCAN_START 0x80u +#define OM_FIRST_OBJECT_SCAN_END 0xE0u #define OM_LOCAL_GUID_OFF 0xC0u #define OBJECT_NEXT_OFF 0x3Cu #define OBJECT_TYPE_OFF 0x14u @@ -105,6 +107,7 @@ typedef struct CharacterSnapshot { int raw_in_world; int player_confirmed; PlayerReadResult player_read_result; + uint32_t first_object_offset; char name[NAME_LIMIT + 1]; char zone[ZONE_LIMIT + 1]; char guild[GUILD_LIMIT + 1]; @@ -134,6 +137,7 @@ static const LabelEntry kRaceLabels[] = { static HANDLE gStopEvent = NULL; static HANDLE gWorkerThread = NULL; static DWORD gWorldReadySince = 0; +static uint32_t gFirstObjectOffset = OM_FIRST_OBJECT_OFF; static int user_address(uintptr_t value) { return value >= (uintptr_t)USER_ADDRESS_MIN && value <= (uintptr_t)USER_ADDRESS_MAX; @@ -548,6 +552,79 @@ static void read_player_guild(const MemoryView *view, uintptr_t player_object, } } +static int object_chain_contains_local_player(const MemoryView *view, uint32_t first, + uint64_t wanted_guid) { + uint32_t current = first; + unsigned visited = 0u; + + while (user_address((uintptr_t)current) && !(current & 1u) && + visited++ < MAX_OBJECT_VISITS) { + uint32_t object_type = 0, next = 0; + uint64_t object_guid = 0; + + if (!memory_u32(view, (uintptr_t)current + OBJECT_TYPE_OFF, &object_type)) + return 0; + if (object_type == PLAYER_OBJECT_TYPE && + memory_u64(view, (uintptr_t)current + OBJECT_GUID_OFF, &object_guid) && + object_guid == wanted_guid) { + return 1; + } + + if (!memory_u32(view, (uintptr_t)current + OBJECT_NEXT_OFF, &next) || + next == current || !user_address((uintptr_t)next)) { + return 0; + } + current = next; + } + return 0; +} + +static int resolve_first_object(const MemoryView *view, uint32_t manager, + uint64_t wanted_guid, uint32_t *first, + uint32_t *resolved_offset) { + uint32_t candidate = 0, offset; + + if (!view || !first || !resolved_offset || !user_address((uintptr_t)manager) || + !wanted_guid) { + return 0; + } + + /* Keep the known 1.12.1 offset first, and remember a validated fallback + * for the lifetime of this WoW process once one is discovered. */ + if (memory_u32(view, (uintptr_t)manager + gFirstObjectOffset, &candidate) && + user_address((uintptr_t)candidate) && + object_chain_contains_local_player(view, candidate, wanted_guid)) { + *first = candidate; + *resolved_offset = gFirstObjectOffset; + return 1; + } + + /* Some compatible clients keep the same object manager/local GUID layout + * while moving the list-head field. Scan only the small aligned region + * around the vanilla field, and accept a candidate only when its chain + * actually contains the local player GUID. This prevents random pointers + * from being treated as object-list heads. */ + for (offset = OM_FIRST_OBJECT_SCAN_START; + offset <= OM_FIRST_OBJECT_SCAN_END; + offset += sizeof(uint32_t)) { + if (offset == gFirstObjectOffset) continue; + candidate = 0; + if (!memory_u32(view, (uintptr_t)manager + offset, &candidate) || + !user_address((uintptr_t)candidate)) { + continue; + } + if (!object_chain_contains_local_player(view, candidate, wanted_guid)) + continue; + + gFirstObjectOffset = offset; + *first = candidate; + *resolved_offset = offset; + return 1; + } + + return 0; +} + static const char *player_read_error(PlayerReadResult result) { switch (result) { case PLAYER_READ_OK: return ""; @@ -567,7 +644,7 @@ static const char *player_read_error(PlayerReadResult result) { } static PlayerReadResult read_local_player(const MemoryView *view, CharacterSnapshot *snapshot) { - uint32_t manager = 0, current = 0, initial_first = 0; + uint32_t manager = 0, current = 0, initial_first = 0, first_offset = 0; uint64_t wanted_guid = 0; unsigned visited = 0u; @@ -575,16 +652,16 @@ static PlayerReadResult read_local_player(const MemoryView *view, CharacterSnaps return PLAYER_READ_NO_MANAGER; if (!stable_local_guid(view, manager, &wanted_guid)) return PLAYER_READ_NO_GUID; - if (!memory_u32(view, (uintptr_t)manager + OM_FIRST_OBJECT_OFF, ¤t) || - !user_address((uintptr_t)current)) + if (!resolve_first_object(view, manager, wanted_guid, ¤t, &first_offset)) return PLAYER_READ_NO_FIRST_OBJECT; + snapshot->first_object_offset = first_offset; initial_first = current; while (user_address((uintptr_t)current) && !(current & 1u) && visited++ < MAX_OBJECT_VISITS) { uint32_t current_first = 0, object_type = 0, descriptors = 0, next = 0; uint64_t object_guid = 0; - if (!memory_u32(view, (uintptr_t)manager + OM_FIRST_OBJECT_OFF, ¤t_first) || + if (!memory_u32(view, (uintptr_t)manager + first_offset, ¤t_first) || current_first != initial_first) return PLAYER_READ_LIST_CHANGED; if (!memory_u32(view, (uintptr_t)current + OBJECT_TYPE_OFF, &object_type)) @@ -804,8 +881,8 @@ static void publish_fault_snapshot(void) { replace_text_file_atomically( "discord_wow_status.json", "{\"v\":1,\"ts\":0,\"ok\":false,\"in_world\":false," - "\"raw_in_world\":false,\"player_confirmed\":false,\"name\":\"\"," - "\"zone\":\"\",\"level\":0,\"faction\":\"\",\"class\":\"\"," + "\"raw_in_world\":false,\"player_confirmed\":false,\"first_object_offset\":0," + "\"name\":\"\",\"zone\":\"\",\"level\":0,\"faction\":\"\",\"class\":\"\"," "\"guild\":\"\",\"race\":\"\",\"build\":5875,\"err\":\"fault\"}"); } @@ -847,7 +924,7 @@ static void publish_character_snapshot(void) { _snprintf( json, sizeof(json), "{\"v\":1,\"ts\":%ld,\"ok\":%s,\"in_world\":%s," - "\"raw_in_world\":%s,\"player_confirmed\":%s," + "\"raw_in_world\":%s,\"player_confirmed\":%s,\"first_object_offset\":%u," "\"name\":\"%s\",\"zone\":\"%s\",\"level\":%u," "\"faction\":\"%s\",\"class\":\"%s\",\"guild\":\"%s\"," "\"race\":\"%s\",\"build\":5875,\"err\":\"%s\"}", @@ -856,6 +933,7 @@ static void publish_character_snapshot(void) { snapshot.in_world ? "true" : "false", snapshot.raw_in_world ? "true" : "false", snapshot.player_confirmed ? "true" : "false", + snapshot.first_object_offset, safe_name, safe_zone, (snapshot.in_world && (mask & SHARE_LEVEL)) ? snapshot.level : 0u, -- 2.52.0 From ee7da41a3f58faa4f7f055ba5694c0d7f6b29438 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 21:36:07 +0200 Subject: [PATCH 32/35] Use client GUID hash table for player lookup --- src/WowPresence.c | 138 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 112 insertions(+), 26 deletions(-) diff --git a/src/WowPresence.c b/src/WowPresence.c index 1b288f7..5cb849c 100644 --- a/src/WowPresence.c +++ b/src/WowPresence.c @@ -94,6 +94,7 @@ typedef enum PlayerReadResult { PLAYER_READ_NO_MANAGER, PLAYER_READ_NO_GUID, PLAYER_READ_NO_FIRST_OBJECT, + PLAYER_READ_GUID_LOOKUP_FAILED, PLAYER_READ_LIST_CHANGED, PLAYER_READ_OBJECT_READ_FAILED, PLAYER_READ_BAD_DESCRIPTORS, @@ -625,12 +626,108 @@ static int resolve_first_object(const MemoryView *view, uint32_t manager, return 0; } +static int lookup_object_by_guid_hash(const MemoryView *view, uint32_t manager, + uint64_t guid, uint32_t *object) { + uint32_t table = 0, mask = 0, low, high, bucket_index; + uintptr_t bucket; + uint32_t current = 0, link_offset = 0; + unsigned visited = 0u; + + if (!view || !object || !user_address((uintptr_t)manager) || !guid) return 0; + *object = 0; + + low = (uint32_t)(guid & 0xFFFFFFFFu); + high = (uint32_t)(guid >> 32); + + if (!memory_u32(view, (uintptr_t)manager + 0x1Cu, &table) || + !user_address((uintptr_t)table)) { + return 0; + } + if (!memory_u32(view, (uintptr_t)manager + 0x24u, &mask) || + mask == 0xFFFFFFFFu) { + return 0; + } + + bucket_index = low & mask; + bucket = (uintptr_t)table + (uintptr_t)bucket_index * 12u; + if (!readable_range(bucket, 12u) || + !memory_u32(view, bucket, &link_offset) || + !memory_u32(view, bucket + 8u, ¤t)) { + return 0; + } + + while (user_address((uintptr_t)current) && !(current & 1u) && + visited++ < MAX_OBJECT_VISITS) { + uint32_t hash_key = 0, object_low = 0, object_high = 0; + uint32_t next_slot = 0, next = 0; + + if (!memory_u32(view, (uintptr_t)current + 0x18u, &hash_key) || + !memory_u32(view, (uintptr_t)current + OBJECT_GUID_OFF, &object_low) || + !memory_u32(view, (uintptr_t)current + OBJECT_GUID_OFF + 4u, &object_high)) { + return 0; + } + + if (hash_key == low && object_low == low && object_high == high) { + *object = current; + return 1; + } + + /* Mirror the client's 1.12.1 GUID hash-chain lookup at 0x464890: + * next = *(current + bucket.link_offset + 4). Keep the arithmetic + * 32-bit just like the client and validate before dereferencing. */ + next_slot = current + link_offset + 4u; + if (!user_address((uintptr_t)next_slot) || + !memory_u32(view, (uintptr_t)next_slot, &next) || + next == current) { + return 0; + } + current = next; + } + + return 0; +} + +static PlayerReadResult read_player_object(const MemoryView *view, uint32_t player_object, + CharacterSnapshot *snapshot) { + uint32_t object_type = 0, descriptors = 0, level = 0, bytes0 = 0; + int have_level, have_identity; + + if (!view || !snapshot || !user_address((uintptr_t)player_object)) + return PLAYER_READ_OBJECT_READ_FAILED; + if (!memory_u32(view, (uintptr_t)player_object + OBJECT_TYPE_OFF, &object_type) || + object_type != PLAYER_OBJECT_TYPE) { + return PLAYER_READ_OBJECT_READ_FAILED; + } + if (!memory_u32(view, (uintptr_t)player_object + OBJECT_DESCRIPTORS_OFF, &descriptors) || + !user_address((uintptr_t)descriptors)) { + return PLAYER_READ_BAD_DESCRIPTORS; + } + + have_level = memory_u32(view, (uintptr_t)descriptors + UNIT_LEVEL_OFF, &level) && + level >= 1u && level <= 80u; + have_identity = memory_u32(view, (uintptr_t)descriptors + UNIT_BYTES0_OFF, &bytes0); + + if (have_level) snapshot->level = level; + if (have_identity) { + snapshot->race_id = bytes0 & 0xFFu; + snapshot->class_id = (bytes0 >> 8) & 0xFFu; + } + + read_player_guild(view, (uintptr_t)player_object, descriptors, + snapshot->guild, sizeof(snapshot->guild)); + + if (!have_level || !have_identity || !snapshot->race_id || !snapshot->class_id) + return PLAYER_READ_BAD_PLAYER_DATA; + return PLAYER_READ_OK; +} + static const char *player_read_error(PlayerReadResult result) { switch (result) { case PLAYER_READ_OK: return ""; case PLAYER_READ_NO_MANAGER: return "object_manager"; case PLAYER_READ_NO_GUID: return "local_guid"; case PLAYER_READ_NO_FIRST_OBJECT: return "first_object"; + case PLAYER_READ_GUID_LOOKUP_FAILED: return "guid_lookup"; case PLAYER_READ_LIST_CHANGED: return "object_list_changed"; case PLAYER_READ_OBJECT_READ_FAILED: return "object_read"; case PLAYER_READ_BAD_DESCRIPTORS: return "descriptors"; @@ -644,7 +741,7 @@ static const char *player_read_error(PlayerReadResult result) { } static PlayerReadResult read_local_player(const MemoryView *view, CharacterSnapshot *snapshot) { - uint32_t manager = 0, current = 0, initial_first = 0, first_offset = 0; + uint32_t manager = 0, player_object = 0, current = 0, initial_first = 0, first_offset = 0; uint64_t wanted_guid = 0; unsigned visited = 0u; @@ -652,13 +749,24 @@ static PlayerReadResult read_local_player(const MemoryView *view, CharacterSnaps return PLAYER_READ_NO_MANAGER; if (!stable_local_guid(view, manager, &wanted_guid)) return PLAYER_READ_NO_GUID; + + /* Preferred path: use the same GUID hash table the 1.12.1 client uses + * internally. This does not depend on the visible-object list head at + * manager+0xAC and is therefore compatible with clients where that field + * is absent, relocated, or represented differently. */ + if (lookup_object_by_guid_hash(view, manager, wanted_guid, &player_object)) { + return read_player_object(view, player_object, snapshot); + } + + /* Compatibility fallback for clients where the GUID hash table is not + * available in the expected layout. */ if (!resolve_first_object(view, manager, wanted_guid, ¤t, &first_offset)) - return PLAYER_READ_NO_FIRST_OBJECT; + return PLAYER_READ_GUID_LOOKUP_FAILED; snapshot->first_object_offset = first_offset; initial_first = current; while (user_address((uintptr_t)current) && !(current & 1u) && visited++ < MAX_OBJECT_VISITS) { - uint32_t current_first = 0, object_type = 0, descriptors = 0, next = 0; + uint32_t current_first = 0, object_type = 0, next = 0; uint64_t object_guid = 0; if (!memory_u32(view, (uintptr_t)manager + first_offset, ¤t_first) || @@ -670,29 +778,7 @@ static PlayerReadResult read_local_player(const MemoryView *view, CharacterSnaps if (object_type == PLAYER_OBJECT_TYPE && memory_u64(view, (uintptr_t)current + OBJECT_GUID_OFF, &object_guid) && object_guid == wanted_guid) { - uint32_t level = 0, bytes0 = 0; - int have_level, have_identity; - - if (!memory_u32(view, (uintptr_t)current + OBJECT_DESCRIPTORS_OFF, &descriptors) || - !user_address((uintptr_t)descriptors)) - return PLAYER_READ_BAD_DESCRIPTORS; - - have_level = memory_u32(view, (uintptr_t)descriptors + UNIT_LEVEL_OFF, &level) && - level >= 1u && level <= 80u; - have_identity = memory_u32(view, (uintptr_t)descriptors + UNIT_BYTES0_OFF, &bytes0); - - if (have_level) snapshot->level = level; - if (have_identity) { - snapshot->race_id = bytes0 & 0xFFu; - snapshot->class_id = (bytes0 >> 8) & 0xFFu; - } - - read_player_guild(view, (uintptr_t)current, descriptors, - snapshot->guild, sizeof(snapshot->guild)); - - if (!have_level || !have_identity || !snapshot->race_id || !snapshot->class_id) - return PLAYER_READ_BAD_PLAYER_DATA; - return PLAYER_READ_OK; + return read_player_object(view, current, snapshot); } if (!memory_u32(view, (uintptr_t)current + OBJECT_NEXT_OFF, &next) || -- 2.52.0 From dfe685406c60fee5012758aee4cdbf18450883d1 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 21:51:20 +0200 Subject: [PATCH 33/35] Use native WoW GUID lookup with validation --- src/WowPresence.c | 70 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/src/WowPresence.c b/src/WowPresence.c index 5cb849c..c7453ee 100644 --- a/src/WowPresence.c +++ b/src/WowPresence.c @@ -26,6 +26,7 @@ #define CLIENT_IMAGE_BASE 0x00400000u #define CLIENT_OBJECT_MANAGER_VA 0x00B41414u +#define CLIENT_GET_OBJECT_BY_GUID_VA 0x00464870u #define CLIENT_IN_WORLD_VA 0x00B4B424u #define CLIENT_PLAYER_AREA_ID_VA 0x00B4E314u #define AREATABLE_RECORDS_VA 0x00C0E048u @@ -108,6 +109,7 @@ typedef struct CharacterSnapshot { int raw_in_world; int player_confirmed; PlayerReadResult player_read_result; + uint32_t lookup_method; uint32_t first_object_offset; char name[NAME_LIMIT + 1]; char zone[ZONE_LIMIT + 1]; @@ -626,6 +628,47 @@ static int resolve_first_object(const MemoryView *view, uint32_t manager, return 0; } +static int lookup_object_by_guid_native(const MemoryView *view, uint64_t guid, + uint32_t *object) { + static const unsigned char signature[] = { + 0x55u, 0x8Bu, 0xECu, 0x8Bu, 0x45u, 0x08u, 0x8Bu, + 0x4Du, 0x0Cu, 0x8Bu, 0xD0u, 0x0Bu, 0xD1u + }; + unsigned char actual[sizeof(signature)]; + uintptr_t address; + uint32_t result = 0; + uint64_t returned_guid = 0; + + if (!view || !object || !guid) return 0; + *object = 0; + address = client_address(view, CLIENT_GET_OBJECT_BY_GUID_VA); + if (!address || !memory_copy(view, address, actual, sizeof(actual)) || + memcmp(actual, signature, sizeof(signature)) != 0) { + return 0; + } + +#ifdef _MSC_VER + __try { + typedef uint32_t (__stdcall *GetObjectByGuidFn)(uint64_t); + GetObjectByGuidFn get_object = (GetObjectByGuidFn)address; + result = get_object(guid); + } __except (EXCEPTION_EXECUTE_HANDLER) { + return 0; + } +#else + return 0; +#endif + + if (!user_address((uintptr_t)result) || + !memory_u64(view, (uintptr_t)result + OBJECT_GUID_OFF, &returned_guid) || + returned_guid != guid) { + return 0; + } + + *object = result; + return 1; +} + static int lookup_object_by_guid_hash(const MemoryView *view, uint32_t manager, uint64_t guid, uint32_t *object) { uint32_t table = 0, mask = 0, low, high, bucket_index; @@ -750,18 +793,26 @@ static PlayerReadResult read_local_player(const MemoryView *view, CharacterSnaps if (!stable_local_guid(view, manager, &wanted_guid)) return PLAYER_READ_NO_GUID; - /* Preferred path: use the same GUID hash table the 1.12.1 client uses - * internally. This does not depend on the visible-object list head at - * manager+0xAC and is therefore compatible with clients where that field - * is absent, relocated, or represented differently. */ - if (lookup_object_by_guid_hash(view, manager, wanted_guid, &player_object)) { + /* Preferred path: call the client's own read-only GUID lookup wrapper. + * The wrapper signature is verified before use, its result is guarded by + * SEH, and the returned object's GUID is checked before any fields are + * consumed. This avoids depending on a particular object-list layout. */ + if (lookup_object_by_guid_native(view, wanted_guid, &player_object)) { + snapshot->lookup_method = 1u; return read_player_object(view, player_object, snapshot); } - /* Compatibility fallback for clients where the GUID hash table is not - * available in the expected layout. */ + /* First compatibility fallback: reproduce the vanilla GUID hash lookup + * without executing client code. */ + if (lookup_object_by_guid_hash(view, manager, wanted_guid, &player_object)) { + snapshot->lookup_method = 2u; + return read_player_object(view, player_object, snapshot); + } + + /* Final compatibility fallback: validated visible-object list lookup. */ if (!resolve_first_object(view, manager, wanted_guid, ¤t, &first_offset)) return PLAYER_READ_GUID_LOOKUP_FAILED; + snapshot->lookup_method = 3u; snapshot->first_object_offset = first_offset; initial_first = current; @@ -967,7 +1018,7 @@ static void publish_fault_snapshot(void) { replace_text_file_atomically( "discord_wow_status.json", "{\"v\":1,\"ts\":0,\"ok\":false,\"in_world\":false," - "\"raw_in_world\":false,\"player_confirmed\":false,\"first_object_offset\":0," + "\"raw_in_world\":false,\"player_confirmed\":false,\"lookup_method\":0,\"first_object_offset\":0," "\"name\":\"\",\"zone\":\"\",\"level\":0,\"faction\":\"\",\"class\":\"\"," "\"guild\":\"\",\"race\":\"\",\"build\":5875,\"err\":\"fault\"}"); } @@ -1010,7 +1061,7 @@ static void publish_character_snapshot(void) { _snprintf( json, sizeof(json), "{\"v\":1,\"ts\":%ld,\"ok\":%s,\"in_world\":%s," - "\"raw_in_world\":%s,\"player_confirmed\":%s,\"first_object_offset\":%u," + "\"raw_in_world\":%s,\"player_confirmed\":%s,\"lookup_method\":%u,\"first_object_offset\":%u," "\"name\":\"%s\",\"zone\":\"%s\",\"level\":%u," "\"faction\":\"%s\",\"class\":\"%s\",\"guild\":\"%s\"," "\"race\":\"%s\",\"build\":5875,\"err\":\"%s\"}", @@ -1019,6 +1070,7 @@ static void publish_character_snapshot(void) { snapshot.in_world ? "true" : "false", snapshot.raw_in_world ? "true" : "false", snapshot.player_confirmed ? "true" : "false", + snapshot.lookup_method, snapshot.first_object_offset, safe_name, safe_zone, -- 2.52.0 From e8deffbc9d4bd9222755e75d5c401cba7f5ff22d Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Tue, 1 Sep 2026 21:59:39 +0200 Subject: [PATCH 34/35] Add multi-layout object traversal for stock 5875 clients --- src/WowPresence.c | 157 +++++++++++++++++++++++++++++++++------------- 1 file changed, 112 insertions(+), 45 deletions(-) diff --git a/src/WowPresence.c b/src/WowPresence.c index c7453ee..8e80c3a 100644 --- a/src/WowPresence.c +++ b/src/WowPresence.c @@ -38,6 +38,7 @@ #define AREATABLE_MAX_RECORDS 65535u #define OM_FIRST_OBJECT_OFF 0xACu +#define OM_OBJECT_LINK_BASE_OFF 0xA4u #define OM_FIRST_OBJECT_SCAN_START 0x80u #define OM_FIRST_OBJECT_SCAN_END 0xE0u #define OM_LOCAL_GUID_OFF 0xC0u @@ -89,6 +90,12 @@ typedef struct MemoryView { uintptr_t module_base; } MemoryView; +typedef enum ClientLayout { + CLIENT_LAYOUT_UNKNOWN = 0, + CLIENT_LAYOUT_DIRECT_NEXT = 1, + CLIENT_LAYOUT_VANILLA_RELATIVE_NEXT = 2 +} ClientLayout; + typedef enum PlayerReadResult { PLAYER_READ_NOT_ATTEMPTED = 0, PLAYER_READ_OK, @@ -109,6 +116,7 @@ typedef struct CharacterSnapshot { int raw_in_world; int player_confirmed; PlayerReadResult player_read_result; + uint32_t layout_id; uint32_t lookup_method; uint32_t first_object_offset; char name[NAME_LIMIT + 1]; @@ -555,8 +563,61 @@ static void read_player_guild(const MemoryView *view, uintptr_t player_object, } } -static int object_chain_contains_local_player(const MemoryView *view, uint32_t first, - uint64_t wanted_guid) { +static ClientLayout detect_client_layout(const MemoryView *view) { + static const unsigned char vanilla_head_signature[] = { + 0xA1u, 0x14u, 0x14u, 0xB4u, 0x00u, 0x53u, + 0x8Bu, 0x98u, 0xACu, 0x00u, 0x00u, 0x00u + }; + static const unsigned char vanilla_next_signature[] = { + 0x8Bu, 0x0Du, 0x14u, 0x14u, 0xB4u, 0x00u, + 0x8Bu, 0x81u, 0xA4u, 0x00u, 0x00u, 0x00u, + 0x03u, 0xC3u, 0x8Bu, 0x58u, 0x04u + }; + unsigned char actual_head[sizeof(vanilla_head_signature)]; + unsigned char actual_next[sizeof(vanilla_next_signature)]; + uintptr_t head = client_address(view, 0x00468380u); + uintptr_t next = client_address(view, 0x004683B9u); + + if (head && next && + memory_copy(view, head, actual_head, sizeof(actual_head)) && + memory_copy(view, next, actual_next, sizeof(actual_next)) && + memcmp(actual_head, vanilla_head_signature, sizeof(actual_head)) == 0 && + memcmp(actual_next, vanilla_next_signature, sizeof(actual_next)) == 0) { + return CLIENT_LAYOUT_VANILLA_RELATIVE_NEXT; + } + + /* Preserve the original WowPresence behavior for already-working + * customized clients. */ + return CLIENT_LAYOUT_DIRECT_NEXT; +} + +static int read_next_object(const MemoryView *view, uint32_t manager, uint32_t current, + ClientLayout layout, uint32_t *next) { + uint32_t value = 0; + if (!view || !next || !user_address((uintptr_t)current)) return 0; + + if (layout == CLIENT_LAYOUT_VANILLA_RELATIVE_NEXT) { + uint32_t link_base = 0; + uintptr_t slot; + if (!user_address((uintptr_t)manager) || + !memory_u32(view, (uintptr_t)manager + OM_OBJECT_LINK_BASE_OFF, &link_base) || + !user_address((uintptr_t)link_base)) { + return 0; + } + slot = (uintptr_t)link_base + (uintptr_t)current + 4u; + if (!user_address(slot) || !memory_u32(view, slot, &value)) return 0; + } else { + if (!memory_u32(view, (uintptr_t)current + OBJECT_NEXT_OFF, &value)) return 0; + } + + if (value == current || !user_address((uintptr_t)value)) return 0; + *next = value; + return 1; +} + +static int object_chain_contains_local_player(const MemoryView *view, uint32_t manager, + uint32_t first, uint64_t wanted_guid, + ClientLayout layout) { uint32_t current = first; unsigned visited = 0u; @@ -573,8 +634,7 @@ static int object_chain_contains_local_player(const MemoryView *view, uint32_t f return 1; } - if (!memory_u32(view, (uintptr_t)current + OBJECT_NEXT_OFF, &next) || - next == current || !user_address((uintptr_t)next)) { + if (!read_next_object(view, manager, current, layout, &next)) { return 0; } current = next; @@ -583,8 +643,8 @@ static int object_chain_contains_local_player(const MemoryView *view, uint32_t f } static int resolve_first_object(const MemoryView *view, uint32_t manager, - uint64_t wanted_guid, uint32_t *first, - uint32_t *resolved_offset) { + uint64_t wanted_guid, ClientLayout layout, + uint32_t *first, uint32_t *resolved_offset) { uint32_t candidate = 0, offset; if (!view || !first || !resolved_offset || !user_address((uintptr_t)manager) || @@ -596,7 +656,7 @@ static int resolve_first_object(const MemoryView *view, uint32_t manager, * for the lifetime of this WoW process once one is discovered. */ if (memory_u32(view, (uintptr_t)manager + gFirstObjectOffset, &candidate) && user_address((uintptr_t)candidate) && - object_chain_contains_local_player(view, candidate, wanted_guid)) { + object_chain_contains_local_player(view, manager, candidate, wanted_guid, layout)) { *first = candidate; *resolved_offset = gFirstObjectOffset; return 1; @@ -616,7 +676,7 @@ static int resolve_first_object(const MemoryView *view, uint32_t manager, !user_address((uintptr_t)candidate)) { continue; } - if (!object_chain_contains_local_player(view, candidate, wanted_guid)) + if (!object_chain_contains_local_player(view, manager, candidate, wanted_guid, layout)) continue; gFirstObjectOffset = offset; @@ -787,57 +847,63 @@ static PlayerReadResult read_local_player(const MemoryView *view, CharacterSnaps uint32_t manager = 0, player_object = 0, current = 0, initial_first = 0, first_offset = 0; uint64_t wanted_guid = 0; unsigned visited = 0u; + ClientLayout layout; if (!view || !snapshot || !object_manager_pointer(view, &manager)) return PLAYER_READ_NO_MANAGER; if (!stable_local_guid(view, manager, &wanted_guid)) return PLAYER_READ_NO_GUID; - /* Preferred path: call the client's own read-only GUID lookup wrapper. - * The wrapper signature is verified before use, its result is guarded by - * SEH, and the returned object's GUID is checked before any fields are - * consumed. This avoids depending on a particular object-list layout. */ + layout = detect_client_layout(view); + snapshot->layout_id = (uint32_t)layout; + + /* Primary path: walk the visible-object list using the link model detected + * from the running client. Stock 1.12.1/5875 uses manager+0xA4 as a + * relative link base, while already-supported customized clients keep the + * direct object+0x3C layout. */ + if (resolve_first_object(view, manager, wanted_guid, layout, ¤t, &first_offset)) { + snapshot->lookup_method = 3u; + snapshot->first_object_offset = first_offset; + initial_first = current; + + while (user_address((uintptr_t)current) && !(current & 1u) && + visited++ < MAX_OBJECT_VISITS) { + uint32_t current_first = 0, object_type = 0, next = 0; + uint64_t object_guid = 0; + + if (!memory_u32(view, (uintptr_t)manager + first_offset, ¤t_first) || + current_first != initial_first) + return PLAYER_READ_LIST_CHANGED; + if (!memory_u32(view, (uintptr_t)current + OBJECT_TYPE_OFF, &object_type)) + return PLAYER_READ_OBJECT_READ_FAILED; + + if (object_type == PLAYER_OBJECT_TYPE && + memory_u64(view, (uintptr_t)current + OBJECT_GUID_OFF, &object_guid) && + object_guid == wanted_guid) { + return read_player_object(view, current, snapshot); + } + + if (!read_next_object(view, manager, current, layout, &next)) + return PLAYER_READ_CHAIN_BROKEN; + current = next; + } + } + + /* Compatibility fallback: the client's own GUID lookup. Signature and + * returned GUID are validated before the object is consumed. */ if (lookup_object_by_guid_native(view, wanted_guid, &player_object)) { snapshot->lookup_method = 1u; return read_player_object(view, player_object, snapshot); } - /* First compatibility fallback: reproduce the vanilla GUID hash lookup - * without executing client code. */ + /* Last fallback: reproduce the GUID hash lookup without executing client + * code. */ if (lookup_object_by_guid_hash(view, manager, wanted_guid, &player_object)) { snapshot->lookup_method = 2u; return read_player_object(view, player_object, snapshot); } - /* Final compatibility fallback: validated visible-object list lookup. */ - if (!resolve_first_object(view, manager, wanted_guid, ¤t, &first_offset)) - return PLAYER_READ_GUID_LOOKUP_FAILED; - snapshot->lookup_method = 3u; - snapshot->first_object_offset = first_offset; - initial_first = current; - - while (user_address((uintptr_t)current) && !(current & 1u) && visited++ < MAX_OBJECT_VISITS) { - uint32_t current_first = 0, object_type = 0, next = 0; - uint64_t object_guid = 0; - - if (!memory_u32(view, (uintptr_t)manager + first_offset, ¤t_first) || - current_first != initial_first) - return PLAYER_READ_LIST_CHANGED; - if (!memory_u32(view, (uintptr_t)current + OBJECT_TYPE_OFF, &object_type)) - return PLAYER_READ_OBJECT_READ_FAILED; - - if (object_type == PLAYER_OBJECT_TYPE && - memory_u64(view, (uintptr_t)current + OBJECT_GUID_OFF, &object_guid) && - object_guid == wanted_guid) { - return read_player_object(view, current, snapshot); - } - - if (!memory_u32(view, (uintptr_t)current + OBJECT_NEXT_OFF, &next) || - next == current || !user_address((uintptr_t)next)) - return PLAYER_READ_CHAIN_BROKEN; - current = next; - } - return PLAYER_READ_NOT_FOUND; + return PLAYER_READ_GUID_LOOKUP_FAILED; } static void collect_character_snapshot(CharacterSnapshot *snapshot) { @@ -1018,7 +1084,7 @@ static void publish_fault_snapshot(void) { replace_text_file_atomically( "discord_wow_status.json", "{\"v\":1,\"ts\":0,\"ok\":false,\"in_world\":false," - "\"raw_in_world\":false,\"player_confirmed\":false,\"lookup_method\":0,\"first_object_offset\":0," + "\"raw_in_world\":false,\"player_confirmed\":false,\"layout_id\":0,\"lookup_method\":0,\"first_object_offset\":0," "\"name\":\"\",\"zone\":\"\",\"level\":0,\"faction\":\"\",\"class\":\"\"," "\"guild\":\"\",\"race\":\"\",\"build\":5875,\"err\":\"fault\"}"); } @@ -1061,7 +1127,7 @@ static void publish_character_snapshot(void) { _snprintf( json, sizeof(json), "{\"v\":1,\"ts\":%ld,\"ok\":%s,\"in_world\":%s," - "\"raw_in_world\":%s,\"player_confirmed\":%s,\"lookup_method\":%u,\"first_object_offset\":%u," + "\"raw_in_world\":%s,\"player_confirmed\":%s,\"layout_id\":%u,\"lookup_method\":%u,\"first_object_offset\":%u," "\"name\":\"%s\",\"zone\":\"%s\",\"level\":%u," "\"faction\":\"%s\",\"class\":\"%s\",\"guild\":\"%s\"," "\"race\":\"%s\",\"build\":5875,\"err\":\"%s\"}", @@ -1070,6 +1136,7 @@ static void publish_character_snapshot(void) { snapshot.in_world ? "true" : "false", snapshot.raw_in_world ? "true" : "false", snapshot.player_confirmed ? "true" : "false", + snapshot.layout_id, snapshot.lookup_method, snapshot.first_object_offset, safe_name, -- 2.52.0 From 74deea17914a508a91b48ce96ff3f2b8af25e438 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Fri, 4 Sep 2026 22:32:45 +0200 Subject: [PATCH 35/35] Initialize WowPresence --- .github/workflows/build.yml | 98 ++++ README.md | 275 ++++++++++- RELEASE_NOTES.md | 22 + THIRD_PARTY_NOTICES.md | 30 ++ config/discord_application_id | 1 + config/discord_broadcast_flags | 1 + src/WowPresence.c | 861 +++++++++++++++++++++++++++++++++ src/WowPresenceLauncher.c | 542 +++++++++++++++++++++ 8 files changed, 1828 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 RELEASE_NOTES.md create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 config/discord_application_id create mode 100644 config/discord_broadcast_flags create mode 100644 src/WowPresence.c create mode 100644 src/WowPresenceLauncher.c diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..3f3ee33 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,98 @@ +name: Build WowPresence + +on: + push: + branches: + - main + tags: + - 'v*' + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: wowpresence-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: windows-2025 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Configure x86 MSVC + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x86 + + - name: Build native components + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "dist" | Out-Null + cl /nologo /O2 /MT /W3 /LD "src\WowPresence.c" /Fe:"dist\WowPresence.dll" /link /INCREMENTAL:NO + if ($LASTEXITCODE -ne 0) { throw "WowPresence.dll build failed" } + + cl /nologo /O2 /MT /W3 "src\WowPresenceLauncher.c" user32.lib /Fe:"dist\WowPresence.exe" /link /SUBSYSTEM:WINDOWS /INCREMENTAL:NO + if ($LASTEXITCODE -ne 0) { throw "WowPresence.exe build failed" } + + if (-not (Test-Path "dist\WowPresence.dll")) { throw "WowPresence.dll missing" } + if (-not (Test-Path "dist\WowPresence.exe")) { throw "WowPresence.exe missing" } + + - name: Create ready-to-install package + shell: pwsh + run: | + $package = "package" + $config = Join-Path $package "WowPresence" + New-Item -ItemType Directory -Force -Path $config | Out-Null + + Copy-Item "dist\WowPresence.dll" (Join-Path $package "WowPresence.dll") + Copy-Item "dist\WowPresence.exe" (Join-Path $package "WowPresence.exe") + Copy-Item "config\discord_application_id" (Join-Path $config "discord_application_id") + Copy-Item "config\discord_broadcast_flags" (Join-Path $config "discord_broadcast_flags") + + $zip = "dist\WowPresence.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path (Join-Path $package "WowPresence.dll"),(Join-Path $package "WowPresence.exe"),(Join-Path $package "WowPresence") -DestinationPath $zip -CompressionLevel Optimal + if (-not (Test-Path $zip)) { throw "WowPresence.zip missing" } + + - name: Upload build artifact + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + uses: actions/upload-artifact@v4 + with: + name: WowPresence-build + path: | + dist/WowPresence.dll + dist/WowPresence.exe + dist/WowPresence.zip + if-no-files-found: error + + - name: Create GitHub Release v1.3 + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.event.head_commit.message == 'release v1.3' }} + uses: softprops/action-gh-release@v3 + with: + tag_name: v1.3 + target_commitish: ${{ github.sha }} + files: | + dist/WowPresence.zip + fail_on_unmatched_files: true + name: "WowPresence v1.3" + body_path: RELEASE_NOTES.md + generate_release_notes: false + make_latest: true + + - name: Create GitHub Release + if: ${{ startsWith(github.ref, 'refs/tags/') }} + uses: softprops/action-gh-release@v3 + with: + files: | + dist/WowPresence.zip + fail_on_unmatched_files: true + name: "WowPresence ${{ github.ref_name }}" + generate_release_notes: true + make_latest: true diff --git a/README.md b/README.md index 083f807..ff58c36 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,273 @@ -# WowPresence -Native Discord Rich Presence for World of Warcraft 1.12.1 (build 5875) and compatible clients. +# 🎮 WowPresence + +Native Discord Rich Presence for **World of Warcraft 1.12.1 (build 5875)** and compatible clients. + +Focused on **simple installation, read-only game sampling and native Discord IPC**. + +> WowPresence targets **WoW 1.12.1 / build 5875-compatible client environments**. + +## 🔌 Requirements + +- **World of Warcraft 1.12.1 (build 5875)** or a compatible client +- **Discord Desktop** +- A compatible DLL loader such as **VanillaFixes** +- A free **Discord Application ID** + +The DLL only reads character status from the game. Discord communication is handled by the separate `WowPresence.exe` companion process. + +## 📦 Installation + +1. Download **`WowPresence.zip`** from Releases. +2. Extract the ZIP directly into your WoW game directory. +3. Open: + +```text +WowPresence\discord_application_id +``` + +4. Replace the placeholder with **your numeric Discord Application ID**. +5. Add `WowPresence.dll` to your VanillaFixes `dlls.txt`. +6. Launch WoW normally through VanillaFixes. + +After extraction, the relevant files should look like this: + +```text +\ +├─ WowPresence.dll +├─ WowPresence.exe +└─ WowPresence\ + ├─ discord_application_id + └─ discord_broadcast_flags +``` + +`WowPresence.exe` is started automatically by the DLL and normally does **not** need to be launched manually. + +The runtime JSON and log files are created automatically inside the same `WowPresence` folder. + +When WowPresence is installed through the **[Modernization Tool](https://github.com/Dusk-92/Modernization-Tool)**, the same binaries automatically use: + +```text +\.modernization_tool\WowPresence\ +``` + +instead. No separate build is required. + +## 🛰️ Discord Application ID + +To configure your own Discord Application ID: + +1. Open the **Discord Developer Portal**: https://discord.com/developers/applications +2. Create a **New Application**. +3. Give it the name you want Discord to display. +4. Open **General Information**. +5. Copy the **Application ID**. +6. Paste only that numeric ID into: + +```text +WowPresence\discord_application_id +``` + +Example: + +```text +123456789012345678 +``` + +For **OctoWoW**, you can use the preconfigured Discord Application ID: + +```text +1544072796098011176 +``` + +This is the same Application ID automatically configured by **Modernization Tool**. + +No Discord Client Secret, bot token or bot account is used or required. + +If `discord_application_id` is missing or invalid, Rich Presence remains disabled and the reason is written to `WowPresence\WowPresence.log`. + +## ✨ Features + +- Character name and guild display. +- Level and class display. +- Race and faction display. +- Current zone display resolved from the client AreaTable instead of heuristic text-address scanning. +- Stable elapsed-session timer across character changes. +- Automatic companion startup. +- Exact WoW process tracking. +- Read-only game-memory sampling. +- Native Discord IPC. +- Optional privacy flags for individual character fields. +- No bot, Client Secret or external service required. + +## 🔧 Compatibility + +### Tested + +- **OctoWoW** — WoW 1.12.1 / build 5875 based client + +### Expected compatible + +- **World of Warcraft 1.12.1 build 5875** +- Private-server clients preserving the relevant 1.12.1 client memory layout + +### Not compatible + +- WoW Classic Era 1.14.x +- The Burning Crusade Classic +- Wrath of the Lich King Classic +- Retail World of Warcraft + +Other modified 1.12.1 clients should be considered **untested until verified**. + +## ⚙️ How it works + +```text +WoW + └─ WowPresence.dll + ├─ read-only character sampling + ├─ Area ID → AreaTable zone resolution + ├─ WowPresence\discord_wow_status.json + └─ starts WowPresence.exe + └─ Discord local IPC +``` + +Zone names are resolved from the player's current AreaTable ID, matching the client's own zone data and avoiding internal map tokens such as `H32D` or `HLVA`. + +The DLL does not communicate directly with Discord. + +The companion executable does not read or write WoW memory. + +Runtime files are kept together in one of these locations: + +**Standalone installation:** + +```text +\WowPresence\ +├─ discord_application_id +├─ discord_broadcast_flags +├─ discord_wow_status.json +└─ WowPresence.log +``` + +**Installed through Modernization Tool:** + +```text +\.modernization_tool\WowPresence\ +├─ discord_application_id +├─ discord_broadcast_flags +├─ discord_wow_status.json +└─ WowPresence.log +``` + +If the Modernization Tool managed folder exists, WowPresence automatically prefers it. Otherwise it uses the standalone `WowPresence` folder. + +## 🔒 Discord Rich Presence privacy + +WowPresence lets you choose exactly which character details are shown on Discord. + +For a standalone installation, edit: + +```text +WowPresence\discord_broadcast_flags +``` + +When WowPresence is installed through **Modernization Tool**, the equivalent file is stored in: + +```text +.modernization_tool\WowPresence\discord_broadcast_flags +``` + +Modernization Tool provides checkboxes for these options, so manual bitmask editing is normally only needed for standalone WowPresence installations. + +### Available fields + +Each field has its own value: + +| Value | Field | +| ---: | --- | +| 1 | Character Name | +| 2 | Guild | +| 4 | Faction | +| 8 | Class | +| 16 | Level | +| 32 | Zone | +| 64 | Race | + +To display multiple fields, **add their values together** and put the result in `discord_broadcast_flags`. + +### Examples + +| Display | Value | +| --- | ---: | +| Nothing | 0 | +| Character Name only | 1 | +| Faction + Class | 12 | +| Race only | 64 | +| Race + Faction + Class | 76 | +| Character Name + Level + Zone | 49 | +| All character details | 127 | + +Example — to display **Race + Faction + Class**: + +```text +Race = 64 +Faction = 4 +Class = 8 + +64 + 4 + 8 = 76 +``` + +Then set: + +```text +76 +``` + +inside `discord_broadcast_flags`. + +The default configuration uses `127`, which enables all supported character details. + +WowPresence reads this setting while the game is running, so changes should normally appear on Discord automatically within a few seconds. + +If the file is missing or contains an invalid value, WowPresence falls back to all supported fields. + +## 🛡️ Administrator privileges + +Discord and WoW should run with matching privilege levels. + +If Discord runs as administrator, WoW must also run as administrator for local Rich Presence IPC to work reliably. + +Running both normally is recommended unless elevation is required by the local setup. + +## 🛠️ Building + +WowPresence targets **32-bit x86 Windows**, matching WoW 1.12.1. + +With an x86 MSVC developer environment: + +```bat +cl /nologo /O2 /MT /W3 /LD src\WowPresence.c /Fe:WowPresence.dll /link /INCREMENTAL:NO +cl /nologo /O2 /MT /W3 src\WowPresenceLauncher.c user32.lib /Fe:WowPresence.exe /link /SUBSYSTEM:WINDOWS /INCREMENTAL:NO +``` + +GitHub Actions builds: + +- `WowPresence.dll` +- `WowPresence.exe` +- `WowPresence.zip` + +The ZIP includes the ready-to-edit `WowPresence` configuration folder. + +Tags matching `v*` publish `WowPresence.zip` as the GitHub Release asset. + +## 📜 Project identity + +WowPresence is an **independent community project**. + +Compatibility with **World of Warcraft**, **Discord**, **OctoWoW**, **VanillaFixes**, or other referenced projects does not imply affiliation, endorsement, sponsorship or ownership by their respective rights holders or maintainers. + +**World of Warcraft**, **Warcraft**, **Blizzard Entertainment**, **Discord**, and related names and marks remain the property of their respective rights holders. + +## 🙏 Credits + +WowPresence is maintained by **Dusk-92**. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..d194519 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,22 @@ +# WowPresence v1.3 + +## What's New + +- Added independent privacy control for: + - Character Name + - Guild + - Race + - Faction + - Class + - Level + - Zone +- Added a dedicated Race flag. +- Full detail mask is now `127`. +- Fixed Discord Rich Presence combinations that previously failed when Name, Zone or Level were not shared. +- Race is no longer always shown and can now be disabled independently. +- Improved handling of empty Discord Rich Presence fields. +- Updated README with standalone privacy configuration examples. + +## Included + +- `WowPresence.zip` — ready-to-install package containing `WowPresence.dll`, `WowPresence.exe`, and the WowPresence configuration folder. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..68da0a4 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,30 @@ +# Third-Party Notices + +WowPresence contains original project code maintained by Dusk-92 and uses compatibility knowledge related to World of Warcraft 1.12.1 and Discord local Rich Presence IPC. + +## ClassicAPI technical reference + +AreaTable and current-player-area client layout details were cross-checked against: + +- Project: ClassicAPI +- Repository: https://github.com/brues-code/ClassicAPI + +ClassicAPI is used as a technical/reference source for documented WoW 1.12.1 client structures. WowPresence does not require, load, or call ClassicAPI. + +## World of Warcraft client compatibility data + +WowPresence uses client-layout information and memory offsets associated with World of Warcraft 1.12.1 build 5875. Such compatibility information has been documented in multiple community projects over the years. + +These values and client structures are used solely to locate read-only character status information in a compatible running client. + +## Discord + +WowPresence communicates with the locally running Discord desktop client through Discord's local IPC / Rich Presence protocol. + +Discord is a trademark of Discord Inc. WowPresence is not affiliated with or endorsed by Discord Inc. + +## Blizzard Entertainment / World of Warcraft + +World of Warcraft, Warcraft, Blizzard, and related names and marks are trademarks or registered trademarks of Blizzard Entertainment, Inc. + +WowPresence is an independent community project and is not affiliated with or endorsed by Blizzard Entertainment. diff --git a/config/discord_application_id b/config/discord_application_id new file mode 100644 index 0000000..004d63e --- /dev/null +++ b/config/discord_application_id @@ -0,0 +1 @@ +PASTE_YOUR_DISCORD_APPLICATION_ID_HERE diff --git a/config/discord_broadcast_flags b/config/discord_broadcast_flags new file mode 100644 index 0000000..c75acbe --- /dev/null +++ b/config/discord_broadcast_flags @@ -0,0 +1 @@ +127 diff --git a/src/WowPresence.c b/src/WowPresence.c new file mode 100644 index 0000000..48bcfbe --- /dev/null +++ b/src/WowPresence.c @@ -0,0 +1,861 @@ +/* + * WowPresence.dll + * Read-only WoW 1.12.1 (build 5875) status sampler for WowPresence. + * + * Runtime contract: + * standalone: \WowPresence\... + * Modernization Tool: \.modernization_tool\WowPresence\... + * + * Discord IPC is intentionally out of process. After the first sample, this + * DLL starts WowPresence.exe from its worker thread and passes the exact + * WoW process id. No process launch is performed from DllMain. + * + * This implementation is organized independently around a small memory-view + * layer and a snapshot serializer. Zone names are resolved from the player's + * current AreaTable ID instead of guessing among raw text addresses. Client + * addresses are community-documented WoW 1.12.1 / build 5875 compatibility + * values used by the target client. + */ +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include + +#define CLIENT_IMAGE_BASE 0x00400000u +#define CLIENT_OBJECT_MANAGER_VA 0x00B41414u +#define CLIENT_IN_WORLD_VA 0x00B4B424u +#define CLIENT_PLAYER_AREA_ID_VA 0x00B4E314u +#define AREATABLE_RECORDS_VA 0x00C0E048u +#define AREATABLE_COUNT_VA 0x00C0E04Cu +#define LOCALE_INDEX_VA 0x00C0E080u +#define AREATABLE_PARENT_ID_OFF 0x08u +#define AREATABLE_NAMES_OFF 0x2Cu +#define AREATABLE_LOCALE_SLOTS 9u +#define AREATABLE_MAX_RECORDS 65535u + +#define OM_FIRST_OBJECT_OFF 0xACu +#define OM_LOCAL_GUID_OFF 0xC0u +#define OBJECT_NEXT_OFF 0x3Cu +#define OBJECT_TYPE_OFF 0x14u +#define OBJECT_GUID_OFF 0x30u +#define OBJECT_DESCRIPTORS_OFF 0x08u +#define UNIT_LEVEL_OFF 0x88u +#define UNIT_BYTES0_OFF 0x90u +#define PLAYER_GUILD_ID_OFF 0x2FCu +#define PLAYER_INFO_OFF 0xE68u +#define PLAYER_INFO_GUILD_KEY_OFF 0x0Cu + +#define GUILD_CACHE_BASE_VA 0x00C0E0C0u +#define GUILD_CACHE_STRIDE 0x3Cu +#define GUILD_CACHE_COUNT 12u +#define GUILD_TAG_WGLD 0x444C4757u +#define GUILD_TAG_DLGW 0x57474C44u + +#define USER_ADDRESS_MIN 0x00010000u +#define USER_ADDRESS_MAX 0x7FFEFFFFu +#define PLAYER_OBJECT_TYPE 4u +#define MAX_OBJECT_VISITS 256u +#define MAX_GUILD_VISITS 32u + +#define STARTUP_WAIT_MS 6000u +#define SAMPLE_PERIOD_MS 2000u +#define WORLD_SETTLE_MS 3000u + +#define NAME_LIMIT 24u +#define ZONE_LIMIT 64u +#define GUILD_LIMIT 48u +#define JSON_BUFFER_SIZE 768u + +#define SHARE_NAME 1u +#define SHARE_GUILD 2u +#define SHARE_FACTION 4u +#define SHARE_CLASS 8u +#define SHARE_LEVEL 16u +#define SHARE_ZONE 32u +#define SHARE_RACE 64u +#define SHARE_ALL 127u + +static const uintptr_t kPlayerNameLocations[] = { + 0x00C27FC8u, 0x00C27D88u, 0x00C27FD8u, 0 +}; + +typedef struct MemoryView { + HANDLE process; + uintptr_t module_base; +} MemoryView; + +typedef struct CharacterSnapshot { + int in_world; + char name[NAME_LIMIT + 1]; + char zone[ZONE_LIMIT + 1]; + char guild[GUILD_LIMIT + 1]; + uint32_t level; + uint32_t race_id; + uint32_t class_id; +} CharacterSnapshot; + +typedef struct LabelEntry { + uint32_t id; + const char *text; +} LabelEntry; + +static const LabelEntry kClassLabels[] = { + {1u, "Warrior"}, {2u, "Paladin"}, {3u, "Hunter"}, {4u, "Rogue"}, + {5u, "Priest"}, {7u, "Shaman"}, {8u, "Mage"}, {9u, "Warlock"}, + {11u, "Druid"}, {0u, ""} +}; + +static const LabelEntry kRaceLabels[] = { + {1u, "Human"}, {2u, "Orc"}, {3u, "Dwarf"}, {4u, "Night Elf"}, + {5u, "Undead"}, {6u, "Tauren"}, {7u, "Gnome"}, {8u, "Troll"}, + {9u, "Goblin"}, {10u, "High Elf"}, {11u, "Draenei"}, + {16u, "High Elf"}, {0u, ""} +}; + +static HANDLE gStopEvent = NULL; +static HANDLE gWorkerThread = NULL; +static DWORD gWorldReadySince = 0; + +static int user_address(uintptr_t value) { + return value >= (uintptr_t)USER_ADDRESS_MIN && value <= (uintptr_t)USER_ADDRESS_MAX; +} + +static int readable_protection(DWORD protection) { + DWORD base = protection & 0xFFu; + if (protection & (PAGE_GUARD | PAGE_NOACCESS)) return 0; + return base == PAGE_READONLY || base == PAGE_READWRITE || + base == PAGE_WRITECOPY || base == PAGE_EXECUTE_READ || + base == PAGE_EXECUTE_READWRITE || base == PAGE_EXECUTE_WRITECOPY; +} + +static int readable_range(uintptr_t start, size_t length) { + uintptr_t cursor, end; + if (!start || !length || length > 4096u) return 0; + end = start + length - 1u; + if (end < start || !user_address(start) || !user_address(end)) return 0; + + cursor = start; + while (cursor <= end) { + MEMORY_BASIC_INFORMATION info; + uintptr_t region_end; + if (!VirtualQuery((LPCVOID)cursor, &info, sizeof(info))) return 0; + if (info.State != MEM_COMMIT || !readable_protection(info.Protect)) return 0; + region_end = (uintptr_t)info.BaseAddress + info.RegionSize; + if (region_end <= cursor) return 0; + if (region_end > end) return 1; + cursor = region_end; + } + return 1; +} + +static void memory_view_init(MemoryView *view) { + if (!view) return; + view->process = GetCurrentProcess(); + view->module_base = (uintptr_t)GetModuleHandleA(NULL); +} + +static uintptr_t client_address(const MemoryView *view, uintptr_t vanilla_va) { + if (!view || !view->module_base || vanilla_va < CLIENT_IMAGE_BASE) return 0; + return view->module_base + (vanilla_va - CLIENT_IMAGE_BASE); +} + +static int memory_copy(const MemoryView *view, uintptr_t address, void *destination, size_t length) { + SIZE_T copied = 0; + if (!view || !view->process || !destination || !readable_range(address, length)) return 0; + if (!ReadProcessMemory(view->process, (LPCVOID)address, destination, length, &copied)) return 0; + return copied == length; +} + +static int memory_u32(const MemoryView *view, uintptr_t address, uint32_t *value) { + uint32_t temp = 0; + if (!value || !memory_copy(view, address, &temp, sizeof(temp))) return 0; + *value = temp; + return 1; +} + +static int memory_u64(const MemoryView *view, uintptr_t address, uint64_t *value) { + uint64_t temp = 0; + if (!value || !memory_copy(view, address, &temp, sizeof(temp))) return 0; + *value = temp; + return 1; +} + +static int memory_ascii(const MemoryView *view, uintptr_t address, char *output, + size_t output_size, size_t max_chars) { + size_t i; + if (!view || !output || output_size < 2u || !max_chars || !user_address(address)) return 0; + output[0] = 0; + if (max_chars + 1u > output_size) max_chars = output_size - 1u; + + for (i = 0; i <= max_chars; ++i) { + unsigned char ch = 0; + if (!memory_copy(view, address + i, &ch, 1u)) { + output[0] = 0; + return 0; + } + if (ch == 0) { + output[i] = 0; + return i > 0u; + } + if (i == max_chars || ch < 32u || ch >= 127u) { + output[0] = 0; + return 0; + } + output[i] = (char)ch; + } + output[0] = 0; + return 0; +} + +static int ascii_letter(char ch) { + return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); +} + +static int valid_player_name(const char *text) { + size_t i, length; + if (!text) return 0; + length = strlen(text); + if (length < 2u || length > 16u || !ascii_letter(text[0])) return 0; + for (i = 1u; i < length; ++i) { + if (!ascii_letter(text[i]) && text[i] != '\'') return 0; + } + return 1; +} + +static int valid_display_text(const char *text, size_t max_length) { + size_t i, length; + if (!text) return 0; + length = strlen(text); + if (length < 2u || length > max_length) return 0; + for (i = 0u; i < length; ++i) { + char ch = text[i]; + if (ascii_letter(ch) || (ch >= '0' && ch <= '9') || ch == ' ' || + ch == '\'' || ch == '-' || ch == ':') { + continue; + } + return 0; + } + return 1; +} + +static int valid_guild_name(const char *text) { + if (!valid_display_text(text, GUILD_LIMIT)) return 0; + return _stricmp(text, "none") != 0; +} + +static int valid_area_name(const char *text) { + size_t i, length; + int has_letter = 0; + if (!text) return 0; + length = strlen(text); + if (length < 2u || length > ZONE_LIMIT) return 0; + for (i = 0u; i < length; ++i) { + if (ascii_letter(text[i])) has_letter = 1; + } + return has_letter; +} + +static int client_u32(const MemoryView *view, uintptr_t vanilla_va, uint32_t *value) { + uintptr_t address = client_address(view, vanilla_va); + return address && memory_u32(view, address, value); +} + +static int area_record(const MemoryView *view, uint32_t area_id, uint32_t *record) { + uint32_t count = 0, records = 0, candidate = 0; + uintptr_t slot; + if (!view || !record || !area_id) return 0; + if (!client_u32(view, AREATABLE_COUNT_VA, &count) || + !count || count > AREATABLE_MAX_RECORDS || area_id > count) { + return 0; + } + if (!client_u32(view, AREATABLE_RECORDS_VA, &records) || + !user_address((uintptr_t)records)) { + return 0; + } + + slot = (uintptr_t)records + (uintptr_t)area_id * sizeof(uint32_t); + if (!memory_u32(view, slot, &candidate) || + !user_address((uintptr_t)candidate)) { + return 0; + } + + *record = candidate; + return 1; +} + +static int area_name_from_record(const MemoryView *view, uint32_t record, + char *output, size_t output_size) { + uint32_t locale = 0; + unsigned pass; + if (!view || !user_address((uintptr_t)record) || !output || output_size < 2u) + return 0; + output[0] = 0; + + if (!client_u32(view, LOCALE_INDEX_VA, &locale) || + locale >= AREATABLE_LOCALE_SLOTS) { + locale = 0; + } + + /* Try the active client locale first. If that string is unavailable or + * not representable by the current ASCII JSON path, fall back to another + * populated locale slot rather than publishing an internal map token. */ + for (pass = 0u; pass < AREATABLE_LOCALE_SLOTS; ++pass) { + unsigned index = pass == 0u ? (unsigned)locale : pass - 1u; + uint32_t name_ptr = 0; + uintptr_t pointer_slot; + + if (pass > 0u && index >= locale) ++index; + if (index >= AREATABLE_LOCALE_SLOTS) continue; + + pointer_slot = (uintptr_t)record + AREATABLE_NAMES_OFF + + (uintptr_t)index * sizeof(uint32_t); + if (!memory_u32(view, pointer_slot, &name_ptr) || + !user_address((uintptr_t)name_ptr)) { + continue; + } + if (memory_ascii(view, (uintptr_t)name_ptr, output, output_size, ZONE_LIMIT) && + valid_area_name(output)) { + return 1; + } + output[0] = 0; + } + return 0; +} + +static int read_player_zone(const MemoryView *view, char *output, size_t output_size) { + uint32_t raw_area = 0, area_id, record = 0, parent_id = 0, parent_record = 0; + if (!view || !output || output_size < 2u) return 0; + output[0] = 0; + + if (!client_u32(view, CLIENT_PLAYER_AREA_ID_VA, &raw_area)) return 0; + area_id = raw_area & 0xFFFFu; + if (!area_id || !area_record(view, area_id, &record)) return 0; + + /* GetRealZoneText resolves sub-areas to their enclosing AreaTable zone. + * Mirror that behavior when a valid parent row is available. */ + if (memory_u32(view, (uintptr_t)record + AREATABLE_PARENT_ID_OFF, &parent_id) && + parent_id && area_record(view, parent_id, &parent_record)) { + record = parent_record; + } + + return area_name_from_record(view, record, output, output_size); +} + +static int client_is_in_world(const MemoryView *view) { + uint32_t value = 0; + return client_u32(view, CLIENT_IN_WORLD_VA, &value) && (value & 0xFFu) != 0u; +} + +static int text_from_location(const MemoryView *view, uintptr_t vanilla_va, + char *output, size_t output_size, size_t max_chars, + int (*validator)(const char *)) { + uintptr_t location; + uint32_t indirect = 0; + if (!view || !validator) return 0; + location = client_address(view, vanilla_va); + if (!location) return 0; + + if (memory_ascii(view, location, output, output_size, max_chars) && validator(output)) + return 1; + + output[0] = 0; + if (!memory_u32(view, location, &indirect) || !user_address((uintptr_t)indirect)) return 0; + if (memory_ascii(view, (uintptr_t)indirect, output, output_size, max_chars) && validator(output)) + return 1; + output[0] = 0; + return 0; +} + +static int first_text_match(const MemoryView *view, const uintptr_t *locations, + char *output, size_t output_size, size_t max_chars, + int (*validator)(const char *)) { + size_t i; + if (!locations) return 0; + for (i = 0u; locations[i] != 0u; ++i) { + if (text_from_location(view, locations[i], output, output_size, max_chars, validator)) + return 1; + } + if (output && output_size) output[0] = 0; + return 0; +} + +static const char *label_for_id(const LabelEntry *table, uint32_t id) { + size_t i; + if (!table || !id) return ""; + for (i = 0u; table[i].id != 0u; ++i) { + if (table[i].id == id) return table[i].text; + } + return ""; +} + +static const char *faction_for_race(uint32_t race_id) { + switch (race_id) { + case 1u: case 3u: case 4u: case 7u: case 10u: case 11u: case 16u: + return "alliance"; + case 2u: case 5u: case 6u: case 8u: case 9u: + return "horde"; + default: + return ""; + } +} + +static int object_manager_pointer(const MemoryView *view, uint32_t *manager) { + uintptr_t slot; + uint32_t value = 0; + if (!view || !manager) return 0; + slot = client_address(view, CLIENT_OBJECT_MANAGER_VA); + if (!slot || !memory_u32(view, slot, &value) || !user_address((uintptr_t)value)) return 0; + *manager = value; + return 1; +} + +static int stable_local_guid(const MemoryView *view, uint32_t manager, uint64_t *guid) { + uint64_t first = 0, second = 0; + uintptr_t address; + if (!view || !guid || !user_address((uintptr_t)manager)) return 0; + address = (uintptr_t)manager + OM_LOCAL_GUID_OFF; + if (!memory_u64(view, address, &first) || !memory_u64(view, address, &second)) return 0; + if (!first || first != second) return 0; + *guid = first; + return 1; +} + +static int contains_guild_marker(const char *text) { + char lowered[32]; + size_t i, length; + if (!text) return 0; + length = strlen(text); + if (length >= sizeof(lowered)) length = sizeof(lowered) - 1u; + for (i = 0u; i < length; ++i) { + char ch = text[i]; + lowered[i] = (ch >= 'A' && ch <= 'Z') ? (char)(ch - 'A' + 'a') : ch; + } + lowered[length] = 0; + return strstr(lowered, "guild") != NULL || strstr(lowered, "wgld") != NULL; +} + +static uintptr_t locate_guild_cache(const MemoryView *view) { + unsigned i; + for (i = 0u; i < GUILD_CACHE_COUNT; ++i) { + uintptr_t candidate = client_address( + view, GUILD_CACHE_BASE_VA + (uintptr_t)i * GUILD_CACHE_STRIDE); + uint32_t tag = 0; + uint32_t file_name_ptr = 0; + char file_name[32]; + + if (!candidate || !readable_range(candidate, 0x30u)) continue; + if (memory_u32(view, candidate + 0x28u, &tag) && + (tag == GUILD_TAG_WGLD || tag == GUILD_TAG_DLGW)) { + return candidate; + } + + file_name[0] = 0; + if (memory_u32(view, candidate + 0x2Cu, &file_name_ptr) && + user_address((uintptr_t)file_name_ptr) && + memory_ascii(view, (uintptr_t)file_name_ptr, file_name, sizeof(file_name), 24u) && + contains_guild_marker(file_name)) { + return candidate; + } + } + return 0; +} + +static int guild_name_from_chain(const MemoryView *view, uint32_t head, uint32_t guild_id, + uint32_t link_offset, char *output, size_t output_size) { + uint32_t node = head; + unsigned visited = 0u; + if (!view || !output || output_size < 2u) return 0; + output[0] = 0; + + while (user_address((uintptr_t)node) && !(node & 1u) && visited++ < MAX_GUILD_VISITS) { + uint32_t key = 0, next = 0; + if (!memory_u32(view, (uintptr_t)node, &key)) return 0; + if (key == guild_id) { + if (memory_ascii(view, (uintptr_t)node + 0x1Cu, output, output_size, GUILD_LIMIT) && + valid_guild_name(output)) return 1; + output[0] = 0; + if (memory_ascii(view, (uintptr_t)node + 0x18u, output, output_size, GUILD_LIMIT) && + valid_guild_name(output)) return 1; + output[0] = 0; + return 0; + } + if (!memory_u32(view, (uintptr_t)node + link_offset, &next) || + next == node || !user_address((uintptr_t)next)) { + return 0; + } + node = next; + } + return 0; +} + +static int resolve_guild_name(const MemoryView *view, uint32_t guild_id, + char *output, size_t output_size) { + uintptr_t cache; + uint32_t buckets = 0, mask = 0, head = 0; + uintptr_t bucket_head; + if (!guild_id || !output || output_size < 2u) return 0; + output[0] = 0; + + cache = locate_guild_cache(view); + if (!cache) return 0; + if (!memory_u32(view, cache + 0x1Cu, &buckets) || !user_address((uintptr_t)buckets)) return 0; + if (!memory_u32(view, cache + 0x24u, &mask)) return 0; + + bucket_head = (uintptr_t)buckets + (uintptr_t)(guild_id & mask) * 12u + 8u; + if (!memory_u32(view, bucket_head, &head) || !user_address((uintptr_t)head)) return 0; + if (guild_name_from_chain(view, head, guild_id, 4u, output, output_size)) return 1; + return guild_name_from_chain(view, head, guild_id, 8u, output, output_size); +} + +static void read_player_guild(const MemoryView *view, uintptr_t player_object, + uint32_t descriptors, char *output, size_t output_size) { + uint32_t guild_id = 0, player_info = 0; + if (!output || !output_size) return; + output[0] = 0; + + if (user_address((uintptr_t)descriptors) && + memory_u32(view, (uintptr_t)descriptors + PLAYER_GUILD_ID_OFF, &guild_id) && guild_id && + resolve_guild_name(view, guild_id, output, output_size)) { + return; + } + + output[0] = 0; + guild_id = 0; + if (memory_u32(view, player_object + PLAYER_INFO_OFF, &player_info) && + user_address((uintptr_t)player_info) && + memory_u32(view, (uintptr_t)player_info + PLAYER_INFO_GUILD_KEY_OFF, &guild_id) && + guild_id) { + resolve_guild_name(view, guild_id, output, output_size); + } +} + +static int read_local_player(const MemoryView *view, CharacterSnapshot *snapshot) { + uint32_t manager = 0, current = 0, initial_first = 0; + uint64_t wanted_guid = 0; + unsigned visited = 0u; + + if (!view || !snapshot || !object_manager_pointer(view, &manager)) return 0; + if (!stable_local_guid(view, manager, &wanted_guid)) return 0; + if (!memory_u32(view, (uintptr_t)manager + OM_FIRST_OBJECT_OFF, ¤t) || + !user_address((uintptr_t)current)) return 0; + initial_first = current; + + while (user_address((uintptr_t)current) && !(current & 1u) && visited++ < MAX_OBJECT_VISITS) { + uint32_t current_first = 0, object_type = 0, descriptors = 0, next = 0; + uint64_t object_guid = 0; + + if (!memory_u32(view, (uintptr_t)manager + OM_FIRST_OBJECT_OFF, ¤t_first) || + current_first != initial_first) return 0; + if (!memory_u32(view, (uintptr_t)current + OBJECT_TYPE_OFF, &object_type)) return 0; + + if (object_type == PLAYER_OBJECT_TYPE && + memory_u64(view, (uintptr_t)current + OBJECT_GUID_OFF, &object_guid) && + object_guid == wanted_guid && + memory_u32(view, (uintptr_t)current + OBJECT_DESCRIPTORS_OFF, &descriptors) && + user_address((uintptr_t)descriptors)) { + uint32_t level = 0, bytes0 = 0; + if (memory_u32(view, (uintptr_t)descriptors + UNIT_LEVEL_OFF, &level) && + level >= 1u && level <= 80u) { + snapshot->level = level; + } + if (memory_u32(view, (uintptr_t)descriptors + UNIT_BYTES0_OFF, &bytes0)) { + snapshot->race_id = bytes0 & 0xFFu; + snapshot->class_id = (bytes0 >> 8) & 0xFFu; + } + read_player_guild(view, (uintptr_t)current, descriptors, + snapshot->guild, sizeof(snapshot->guild)); + return 1; + } + + if (!memory_u32(view, (uintptr_t)current + OBJECT_NEXT_OFF, &next) || + next == current || !user_address((uintptr_t)next)) return 0; + current = next; + } + return 0; +} + +static void collect_character_snapshot(CharacterSnapshot *snapshot) { + MemoryView view; + DWORD now; + int have_name, have_zone; + if (!snapshot) return; + memset(snapshot, 0, sizeof(*snapshot)); + memory_view_init(&view); + + have_name = first_text_match(&view, kPlayerNameLocations, snapshot->name, + sizeof(snapshot->name), NAME_LIMIT, valid_player_name); + have_zone = read_player_zone(&view, snapshot->zone, sizeof(snapshot->zone)); + snapshot->in_world = client_is_in_world(&view) && have_name && have_zone; + + now = GetTickCount(); + if (!snapshot->in_world) { + gWorldReadySince = 0; + return; + } + + if (!gWorldReadySince) gWorldReadySince = now ? now : 1u; + if ((DWORD)(now - gWorldReadySince) < WORLD_SETTLE_MS) return; + read_local_player(&view, snapshot); +} + +static int game_folder(char *output, size_t output_size) { + char path[MAX_PATH]; + char *separator; + size_t length; + if (!output || output_size < 4u) return 0; + if (!GetModuleFileNameA(NULL, path, MAX_PATH)) return 0; + separator = strrchr(path, '\\'); + if (!separator) separator = strrchr(path, '/'); + if (!separator) return 0; + *separator = 0; + length = strlen(path); + if (length + 1u > output_size) return 0; + memcpy(output, path, length + 1u); + 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_folder(char *output, size_t output_size) { + char root[MAX_PATH], managed[MAX_PATH]; + int count; + if (!game_folder(root, sizeof(root))) return 0; + + /* Modernization Tool creates this directory before WoW starts. Prefer it + * when present so the same binaries can use the tool-managed location. */ + count = _snprintf( + managed, + sizeof(managed), + "%s\\.modernization_tool\\WowPresence", + root + ); + managed[sizeof(managed) - 1] = 0; + if (count >= 0 && (size_t)count < sizeof(managed) && directory_exists(managed)) { + if (strlen(managed) + 1u > output_size) return 0; + lstrcpynA(output, managed, (int)output_size); + return 1; + } + + /* Standalone installs keep their data in \\WowPresence. */ + count = _snprintf(output, output_size, "%s\\WowPresence", root); + if (output_size) output[output_size - 1] = 0; + if (count < 0 || (size_t)count >= output_size) return 0; + CreateDirectoryA(output, NULL); + return 1; +} + +static int support_path(char *output, size_t output_size, const char *file_name) { + char folder[MAX_PATH]; + int count; + if (!file_name || !support_folder(folder, sizeof(folder))) return 0; + count = _snprintf(output, output_size, "%s\\%s", folder, file_name); + if (output_size) output[output_size - 1] = 0; + return count >= 0 && (size_t)count < output_size; +} + +static unsigned load_share_mask(void) { + char path[MAX_PATH], text[32]; + HANDLE file; + DWORD read_count = 0; + char *end = NULL; + unsigned long value; + memset(text, 0, sizeof(text)); + + if (!support_path(path, sizeof(path), "discord_broadcast_flags")) return SHARE_ALL; + 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 SHARE_ALL; + if (!ReadFile(file, text, sizeof(text) - 1u, &read_count, NULL) || !read_count) { + CloseHandle(file); + return SHARE_ALL; + } + CloseHandle(file); + text[read_count < sizeof(text) ? read_count : sizeof(text) - 1u] = 0; + value = strtoul(text, &end, 10); + if (end == text) return SHARE_ALL; + return (unsigned)(value & SHARE_ALL); +} + +static void json_quote_text(const char *source, char *output, size_t output_size) { + size_t read_index = 0u, write_index = 0u; + if (!output || !output_size) return; + output[0] = 0; + + while (source && source[read_index] && write_index + 1u < output_size) { + unsigned char ch = (unsigned char)source[read_index++]; + if (ch == '"' || ch == '\\') { + if (write_index + 2u >= output_size) break; + output[write_index++] = '\\'; + output[write_index++] = (char)ch; + } else if (ch >= 32u && ch < 127u) { + output[write_index++] = (char)ch; + } + } + output[write_index] = 0; +} + +static int replace_text_file_atomically(const char *file_name, const char *contents) { + char destination[MAX_PATH], temporary[MAX_PATH]; + HANDLE file; + DWORD length, written = 0; + int count; + + if (!file_name || !contents || !support_path(destination, sizeof(destination), file_name)) return 0; + count = _snprintf(temporary, sizeof(temporary), "%s.tmp", destination); + temporary[sizeof(temporary) - 1] = 0; + if (count < 0 || (size_t)count >= sizeof(temporary)) return 0; + + file = CreateFileA(temporary, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, NULL); + if (file == INVALID_HANDLE_VALUE) return 0; + length = (DWORD)strlen(contents); + if (!WriteFile(file, contents, length, &written, NULL) || written != length) { + CloseHandle(file); + DeleteFileA(temporary); + return 0; + } + FlushFileBuffers(file); + CloseHandle(file); + + if (!MoveFileExA(temporary, destination, + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + DeleteFileA(temporary); + return 0; + } + return 1; +} + +static void publish_fault_snapshot(void) { + replace_text_file_atomically( + "discord_wow_status.json", + "{\"v\":1,\"ts\":0,\"ok\":false,\"in_world\":false,\"name\":\"\"," + "\"zone\":\"\",\"level\":0,\"faction\":\"\",\"class\":\"\"," + "\"guild\":\"\",\"race\":\"\",\"build\":5875,\"err\":\"fault\"}"); +} + +static void publish_character_snapshot(void) { + CharacterSnapshot snapshot; + unsigned mask; + char safe_name[NAME_LIMIT * 2u + 8u]; + char safe_zone[ZONE_LIMIT * 2u + 8u]; + char safe_guild[GUILD_LIMIT * 2u + 8u]; + char json[JSON_BUFFER_SIZE]; + const char *race_text, *class_text, *faction_text; + + collect_character_snapshot(&snapshot); + mask = load_share_mask(); + race_text = (snapshot.in_world && (mask & SHARE_RACE)) + ? label_for_id(kRaceLabels, snapshot.race_id) : ""; + class_text = (snapshot.in_world && (mask & SHARE_CLASS)) + ? label_for_id(kClassLabels, snapshot.class_id) : ""; + faction_text = (snapshot.in_world && (mask & SHARE_FACTION)) + ? faction_for_race(snapshot.race_id) : ""; + + json_quote_text((mask & SHARE_NAME) ? snapshot.name : "", safe_name, sizeof(safe_name)); + json_quote_text((mask & SHARE_ZONE) ? snapshot.zone : "", safe_zone, sizeof(safe_zone)); + json_quote_text((mask & SHARE_GUILD) ? snapshot.guild : "", safe_guild, sizeof(safe_guild)); + + _snprintf( + json, sizeof(json), + "{\"v\":1,\"ts\":%ld,\"ok\":%s,\"in_world\":%s," + "\"name\":\"%s\",\"zone\":\"%s\",\"level\":%u," + "\"faction\":\"%s\",\"class\":\"%s\",\"guild\":\"%s\"," + "\"race\":\"%s\",\"build\":5875,\"err\":\"%s\"}", + (long)time(NULL), + snapshot.in_world ? "true" : "false", + snapshot.in_world ? "true" : "false", + safe_name, + safe_zone, + (snapshot.in_world && (mask & SHARE_LEVEL)) ? snapshot.level : 0u, + faction_text, + class_text, + safe_guild, + race_text, + snapshot.in_world ? "" : "offsets"); + json[sizeof(json) - 1] = 0; + replace_text_file_atomically("discord_wow_status.json", json); +} + +static int launch_discord_companion(void) { + char root[MAX_PATH], executable[MAX_PATH], command[MAX_PATH * 2u]; + STARTUPINFOA startup; + PROCESS_INFORMATION process; + DWORD attributes; + int count; + + if (!game_folder(root, sizeof(root))) return 0; + count = _snprintf(executable, sizeof(executable), "%s\\WowPresence.exe", root); + executable[sizeof(executable) - 1] = 0; + if (count < 0 || (size_t)count >= sizeof(executable)) return 0; + + attributes = GetFileAttributesA(executable); + if (attributes == INVALID_FILE_ATTRIBUTES || (attributes & FILE_ATTRIBUTE_DIRECTORY)) return 0; + + count = _snprintf(command, sizeof(command), "\"%s\" --pid %lu", + executable, (unsigned long)GetCurrentProcessId()); + command[sizeof(command) - 1] = 0; + if (count < 0 || (size_t)count >= sizeof(command)) return 0; + + memset(&startup, 0, sizeof(startup)); + memset(&process, 0, sizeof(process)); + startup.cb = sizeof(startup); + startup.dwFlags = STARTF_USESHOWWINDOW; + startup.wShowWindow = SW_HIDE; + + if (!CreateProcessA(executable, command, NULL, NULL, FALSE, CREATE_NO_WINDOW, + NULL, root, &startup, &process)) { + return 0; + } + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + return 1; +} + +static DWORD WINAPI presence_worker(LPVOID parameter) { + int companion_running = 0; + (void)parameter; + + if (WaitForSingleObject(gStopEvent, STARTUP_WAIT_MS) != WAIT_TIMEOUT) return 0; + do { +#ifdef _MSC_VER + __try { + publish_character_snapshot(); + } __except (EXCEPTION_EXECUTE_HANDLER) { + publish_fault_snapshot(); + } +#else + publish_character_snapshot(); +#endif + if (!companion_running) companion_running = launch_discord_companion(); + } while (WaitForSingleObject(gStopEvent, SAMPLE_PERIOD_MS) == WAIT_TIMEOUT); + return 0; +} + +BOOL WINAPI DllMain(HINSTANCE module, DWORD reason, LPVOID reserved) { + (void)reserved; + if (reason == DLL_PROCESS_ATTACH) { + DisableThreadLibraryCalls(module); + gStopEvent = CreateEventA(NULL, TRUE, FALSE, NULL); + if (gStopEvent) gWorkerThread = CreateThread(NULL, 0, presence_worker, NULL, 0, NULL); + } else if (reason == DLL_PROCESS_DETACH) { + if (gStopEvent) SetEvent(gStopEvent); + if (gWorkerThread) { + WaitForSingleObject(gWorkerThread, 1500u); + CloseHandle(gWorkerThread); + gWorkerThread = NULL; + } + if (gStopEvent) { + CloseHandle(gStopEvent); + gStopEvent = NULL; + } + } + return TRUE; +} diff --git a/src/WowPresenceLauncher.c b/src/WowPresenceLauncher.c new file mode 100644 index 0000000..285bc07 --- /dev/null +++ b/src/WowPresenceLauncher.c @@ -0,0 +1,542 @@ +/* + * 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 +#include +#include +#include +#include +#include +#include + +#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; +} -- 2.52.0