diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7016ceb --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,102 @@ +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.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.2 + target_commitish: ${{ github.sha }} + files: | + dist/WowPresence.dll + dist/WowPresence.exe + dist/WowPresence.zip + fail_on_unmatched_files: true + name: "WowPresence v1.2" + 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 + 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..d4aae9c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,238 @@ -# 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 **Modernization Tool**, the same binaries automatically use: + +```text +\.modernization_tool\WowPresence\ +``` + +instead. No separate build is required. + +## 🛰️ 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: + + +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 +``` + +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. + +## 🔒 Privacy flags + +The included file: + +```text +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 | + +The release package uses `63` by default. + +If the file is deleted, WowPresence also defaults 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*` 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 implementation in **IchaLaunch** by **brutaliccus**. + +The current WowPresence implementation is maintained separately and does not include IchaLaunch binaries. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..4c6f319 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,16 @@ +# WowPresence v1.2 + +## Changes + +- 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 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..6b62136 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,44 @@ +# 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. + +## 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..4b9026d --- /dev/null +++ b/config/discord_broadcast_flags @@ -0,0 +1 @@ +63 diff --git a/src/WowPresence.c b/src/WowPresence.c new file mode 100644 index 0000000..8e80c3a --- /dev/null +++ b/src/WowPresence.c @@ -0,0 +1,1228 @@ +/* + * 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_GET_OBJECT_BY_GUID_VA 0x00464870u +#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_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 +#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 +}; + +typedef struct MemoryView { + HANDLE process; + 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, + 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, + 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; + uint32_t layout_id; + uint32_t lookup_method; + uint32_t first_object_offset; + 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 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; +} + +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 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; + + 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 (!read_next_object(view, manager, current, layout, &next)) { + return 0; + } + current = next; + } + return 0; +} + +static int resolve_first_object(const MemoryView *view, uint32_t manager, + 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) || + !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, manager, candidate, wanted_guid, layout)) { + *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, manager, candidate, wanted_guid, layout)) + continue; + + gFirstObjectOffset = offset; + *first = candidate; + *resolved_offset = offset; + return 1; + } + + 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; + 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"; + 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, 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; + + 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); + } + + /* 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); + } + + return PLAYER_READ_GUID_LOOKUP_FAILED; +} + +static void collect_character_snapshot(CharacterSnapshot *snapshot) { + MemoryView view; + DWORD now; + 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->raw_in_world = client_is_in_world(&view); + + /* 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; + + /* 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) { + 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," + "\"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\"}"); +} + +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, *error_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)); + + 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 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( + json, sizeof(json), + "{\"v\":1,\"ts\":%ld,\"ok\":%s,\"in_world\":%s," + "\"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\"}", + (long)time(NULL), + snapshot.in_world ? "true" : "false", + 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, + safe_zone, + (snapshot.in_world && (mask & SHARE_LEVEL)) ? snapshot.level : 0u, + faction_text, + class_text, + safe_guild, + race_text, + error_text); + 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..fe714b9 --- /dev/null +++ b/src/WowPresenceLauncher.c @@ -0,0 +1,522 @@ +/* + * 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 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 + +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; +} + +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("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) 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; +}