diff --git a/BH/BH.cpp b/BH/BH.cpp index 984d385b..e2d27f99 100644 --- a/BH/BH.cpp +++ b/BH/BH.cpp @@ -106,6 +106,8 @@ void BH::Initialize() new StashExport(); new MapNotify(); new ChatColor(); + new ChatItemLinks(); + new ChatOverhaul(); moduleManager->LoadModules(); diff --git a/BH/BH.h b/BH/BH.h index ecf3cb63..928fe4ae 100644 --- a/BH/BH.h +++ b/BH/BH.h @@ -184,11 +184,32 @@ struct BHApp SettingsBool usingHDText = { false, false }; } d2gl; + struct + { + SettingsBool enabled = { true, true }; // chat item links (Modules/ItemLinks) + } itemLinks; + struct { pd2PlaySoundImpl_t pd2PlaySoundImpl = NULL; } pd2; + struct + { + SettingsBool enabled = { true, true }; // false: vanilla chat, nothing hooked + SettingsBool timestamps = { false, false }; // "[hh:mm]" before every line + SettingsBool mentionHighlight = { true, true }; + SettingsInt mentionSound = { 0, 0, 0, 4000 }; // Sounds.txt row, 0 = silent + SettingsArray mentionKeywords = { {}, {} }; // besides your character name + SettingsInt historyLines = { 500, 500, 50, 2000 }; // lines kept (all tabs share them) + SettingsInt visibleRows = { 14, 14, 4, 40 }; // rows shown while the panel is open + SettingsInt width = { 440, 440, 200, 1000 }; // panel width in px + SettingsInt fadeSeconds = { 10, 10, 0, 300 }; // closed panel: recent lines stay this long + SettingsInt fadeRows = { 8, 8, 0, 40 }; // closed panel: at most this many rows + SettingsBool alwaysOpen = { false, false }; // keep the full panel up while not typing + SettingsKey replyHotkey = { 0x52, 0x52 }; // Ctrl + this key: "/w " + } chat; + std::vector hotkeyToggles; }; diff --git a/BH/BH.vcxproj b/BH/BH.vcxproj index 8ddb85f4..d8e1534b 100644 --- a/BH/BH.vcxproj +++ b/BH/BH.vcxproj @@ -188,10 +188,15 @@ powershell.exe -NoProfile -File "$(ProjectDir)..\..\scripts\CopyDataFolder.ps1"< + + + + + @@ -240,10 +245,16 @@ powershell.exe -NoProfile -File "$(ProjectDir)..\..\scripts\CopyDataFolder.ps1"< + + + + + + diff --git a/BH/BH.vcxproj.filters b/BH/BH.vcxproj.filters index aea5feb3..2f5d2e6a 100644 --- a/BH/BH.vcxproj.filters +++ b/BH/BH.vcxproj.filters @@ -69,6 +69,20 @@ Modules\ChatColor + + Modules\ItemLinks + + + Modules\ItemLinks + + + Modules\ItemLinks + + Modules\Chat + + + Modules\Chat + Modules\Gamefilter @@ -174,6 +188,23 @@ Modules\ChatColor + + Modules\ItemLinks + + + Modules\ItemLinks + + + Modules\ItemLinks + + + Modules\ItemLinks + + Modules\Chat + + + Modules\Chat + Modules\Gamefilter @@ -258,6 +289,12 @@ {f652bd02-2b0b-46a4-843c-20a0a9ea472d} + + {6d2a8f41-3b7e-4c19-9e5a-0f4b2c7d8e13} + + + {6b1f0f62-3c1e-4d7e-9a57-2f0c4e8a91d3} + {b2342ae5-3b17-4a7e-bd84-a5173ecce46d} diff --git a/BH/CMakeLists.txt b/BH/CMakeLists.txt index 4ea9a0de..4a58649b 100644 --- a/BH/CMakeLists.txt +++ b/BH/CMakeLists.txt @@ -23,9 +23,14 @@ set(MODULE_SOURCES "Modules/Module.cpp" "Modules/ModuleManager.cpp" "Modules/Bnet/Bnet.cpp" "Modules/ChatColor/ChatColor.cpp" + "Modules/Chat/ChatModel.cpp" + "Modules/Chat/ChatOverhaul.cpp" "Modules/Gamefilter/Gamefilter.cpp" "Modules/Item/Item.cpp" "Modules/Item/ItemDisplay.cpp" + "Modules/ItemLinks/ItemLinkCodec.cpp" + "Modules/ItemLinks/ItemLinkGame.cpp" + "Modules/ItemLinks/ItemLinks.cpp" "Modules/ItemMover/ItemMover.cpp" "Modules/Party/Party.cpp" "Modules/ScreenInfo/ScreenInfo.cpp" diff --git a/BH/Config.cpp b/BH/Config.cpp index 72cba084..ce5d95c9 100644 --- a/BH/Config.cpp +++ b/BH/Config.cpp @@ -237,6 +237,11 @@ void Config::SaveConfig() jsonGame["always_show_items"] = App.game.alwaysShowItems.value; App.jsonConfig["game"] = jsonGame; + // Chat item links + json jsonItemLinks; + jsonItemLinks["enabled"] = App.itemLinks.enabled.value; + App.jsonConfig["item_links"] = jsonItemLinks; + // Party settings json jsonParty; jsonParty["auto_party"]["enabled"] = App.party.autoParty.toggle.isEnabled; @@ -264,6 +269,22 @@ void Config::SaveConfig() jsonStash["mustache_format"] = App.stash.mustacheFormat.values; App.jsonConfig["stash_export"] = jsonStash; + // Chat + json jsonChat; + jsonChat["enabled"] = App.chat.enabled.value; + jsonChat["timestamps"] = App.chat.timestamps.value; + jsonChat["mention_highlight"] = App.chat.mentionHighlight.value; + jsonChat["mention_sound"] = App.chat.mentionSound.value; + jsonChat["mention_keywords"] = App.chat.mentionKeywords.values; + jsonChat["history_lines"] = App.chat.historyLines.value; + jsonChat["visible_rows"] = App.chat.visibleRows.value; + jsonChat["width"] = App.chat.width.value; + jsonChat["fade_seconds"] = App.chat.fadeSeconds.value; + jsonChat["fade_rows"] = App.chat.fadeRows.value; + jsonChat["always_open"] = App.chat.alwaysOpen.value; + jsonChat["reply_hotkey"] = GetKeyCode(App.chat.replyHotkey.hotkey).name; + App.jsonConfig["chat"] = jsonChat; + // BH UI window json jsonBHUI; jsonBHUI["is_minimized"] = App.bhui.isMinimized.value; @@ -391,6 +412,9 @@ void Config::LoadConfig() App.game.experienceMeter.value = GetBool("/game"_json_pointer, "experience_meter", App.game.experienceMeter); App.game.alwaysShowItems.value = GetBool("/game"_json_pointer, "always_show_items", App.game.alwaysShowItems); + // Chat item links + App.itemLinks.enabled.value = GetBool("/item_links"_json_pointer, "enabled", App.itemLinks.enabled); + // Party settings App.party.autoParty.toggle = GetToggle("/party"_json_pointer, "auto_party", App.party.autoParty); App.party.autoCorpseLoot.toggle = GetToggle("/party"_json_pointer, "auto_corpse_loot", App.party.autoCorpseLoot); @@ -408,6 +432,20 @@ void Config::LoadConfig() App.stash.mustacheOptions.values = GetArray("/stash_export"_json_pointer, "mustache_options", App.stash.mustacheOptions); App.stash.mustacheFormat.values = GetAssoc("/stash_export"_json_pointer, "mustache_format", App.stash.mustacheFormat); + // Chat + App.chat.enabled.value = GetBool("/chat"_json_pointer, "enabled", App.chat.enabled); + App.chat.timestamps.value = GetBool("/chat"_json_pointer, "timestamps", App.chat.timestamps); + App.chat.mentionHighlight.value = GetBool("/chat"_json_pointer, "mention_highlight", App.chat.mentionHighlight); + App.chat.mentionSound.value = GetInt("/chat"_json_pointer, "mention_sound", App.chat.mentionSound); + App.chat.mentionKeywords.values = GetArray("/chat"_json_pointer, "mention_keywords", App.chat.mentionKeywords); + App.chat.historyLines.value = GetInt("/chat"_json_pointer, "history_lines", App.chat.historyLines); + App.chat.visibleRows.value = GetInt("/chat"_json_pointer, "visible_rows", App.chat.visibleRows); + App.chat.width.value = GetInt("/chat"_json_pointer, "width", App.chat.width); + App.chat.fadeSeconds.value = GetInt("/chat"_json_pointer, "fade_seconds", App.chat.fadeSeconds); + App.chat.fadeRows.value = GetInt("/chat"_json_pointer, "fade_rows", App.chat.fadeRows); + App.chat.alwaysOpen.value = GetBool("/chat"_json_pointer, "always_open", App.chat.alwaysOpen); + App.chat.replyHotkey.hotkey = GetKey("/chat"_json_pointer, "reply_hotkey", App.chat.replyHotkey); + App.bhui.isMinimized.value = GetBool("/bh_ui"_json_pointer, "is_minimized", App.bhui.isMinimized); App.bhui.openedX.value = GetInt("/bh_ui"_json_pointer, "opened_x", App.bhui.openedX); App.bhui.openedY.value = GetInt("/bh_ui"_json_pointer, "opened_y", App.bhui.openedY); diff --git a/BH/Modules.h b/BH/Modules.h index efc62d03..2e52da83 100644 --- a/BH/Modules.h +++ b/BH/Modules.h @@ -11,4 +11,6 @@ #include "Modules\Party\Party.h" #include "Modules\StashExport\StashExport.h" #include "Modules\ChatColor\ChatColor.h" +#include "Modules\ItemLinks\ItemLinks.h" #include "Modules/GameSettings/GameSettings.h" +#include "Modules/Chat/ChatOverhaul.h" diff --git a/BH/Modules/Chat/ChatModel.cpp b/BH/Modules/Chat/ChatModel.cpp new file mode 100644 index 00000000..ca1c040a --- /dev/null +++ b/BH/Modules/Chat/ChatModel.cpp @@ -0,0 +1,305 @@ +#include "ChatModel.h" + +namespace ChatModel { + +const wchar_t* TabName(int tab) { + switch (tab) { + case TAB_ALL: return L"All"; + case TAB_GAME: return L"Game"; + case TAB_WHISPERS: return L"Whispers"; + case TAB_SYSTEM: return L"System"; + case TAB_GLOBAL: return L"Global"; + } + return L""; +} + +Kind Classify(const Source& s) { + switch (s.origin) { + case ORIGIN_BNET_WHISPER_IN: return KIND_WHISPER_IN; + case ORIGIN_BNET_WHISPER_OUT: return KIND_WHISPER_OUT; + case ORIGIN_CHAT_PACKET: + switch (s.chatType) { + case 2: return KIND_WHISPER_IN; + case 6: return KIND_WHISPER_OUT; + case 1: + // Only unitType >= 2 is printed as ": "; 0/1 print the bare message. + return s.unitType < 2 ? KIND_SYSTEM : KIND_CHAT; + } + return KIND_SYSTEM; + case ORIGIN_NONE: + break; + } + return KIND_SYSTEM; +} + +unsigned TabsFor(Kind k) { + unsigned m = TabBit(TAB_ALL); + switch (k) { + case KIND_CHAT: m |= TabBit(TAB_GAME); break; + case KIND_WHISPER_IN: + case KIND_WHISPER_OUT: m |= TabBit(TAB_WHISPERS); break; + case KIND_SYSTEM: m |= TabBit(TAB_SYSTEM); break; + } + return m; +} + +std::wstring StripColorCodes(const std::wstring& s) { + std::wstring out; + out.reserve(s.size()); + for (size_t i = 0; i < s.size(); i++) { + if (s[i] == kColorChar && i + 1 < s.size() && s[i + 1] == L'c') { + i += 2; // skip the code character too (if present) + continue; + } + out += s[i]; + } + return out; +} + +namespace { + +wchar_t Fold(wchar_t c) { + if (c >= L'A' && c <= L'Z') return (wchar_t)(c + 32); + if (c >= 0xC0 && c <= 0xDE && c != 0xD7) return (wchar_t)(c + 32); + return c; +} + +bool IsWordChar(wchar_t c) { + if ((c >= L'a' && c <= L'z') || (c >= L'A' && c <= L'Z') || (c >= L'0' && c <= L'9') || c == L'_') return true; + return c >= 0xC0 && c <= 0xFF && c != 0xD7 && c != 0xF7; +} + +} // namespace + +bool ContainsWord(const std::wstring& haystack, const std::wstring& needle) { + if (needle.empty()) return false; + std::wstring h = StripColorCodes(haystack); + if (needle.size() > h.size()) return false; + for (size_t i = 0; i + needle.size() <= h.size(); i++) { + size_t k = 0; + while (k < needle.size() && Fold(h[i + k]) == Fold(needle[k])) k++; + if (k != needle.size()) continue; + bool before = i > 0 && IsWordChar(h[i - 1]) && IsWordChar(needle[0]); + size_t end = i + needle.size(); + bool after = end < h.size() && IsWordChar(h[end]) && IsWordChar(needle[needle.size() - 1]); + if (!before && !after) return true; + } + return false; +} + +bool IsMention(const std::wstring& body, const std::wstring& myName, const std::vector& keywords) { + if (ContainsWord(body, myName)) return true; + for (const std::wstring& k : keywords) + if (ContainsWord(body, k)) return true; + return false; +} + +std::wstring Timestamp(int hour, int minute) { + if (hour < 0) hour = 0; + if (hour > 23) hour = 23; + if (minute < 0) minute = 0; + if (minute > 59) minute = 59; + wchar_t b[8] = { L'[', (wchar_t)(L'0' + hour / 10), (wchar_t)(L'0' + hour % 10), L':', + (wchar_t)(L'0' + minute / 10), (wchar_t)(L'0' + minute % 10), L']', 0 }; + return b; +} + +namespace { + +enum AtomKind { A_WORD, A_SPACE, A_CODE, A_LINK }; +struct Atom { + AtomKind kind; + std::wstring text; + int color; + int link; +}; + +void Tokenize(const std::vector& segs, std::vector& out) { + for (const Seg& s : segs) { + if (s.link) { + out.push_back({ A_LINK, s.text, s.color, s.link }); + continue; + } + const std::wstring& t = s.text; + size_t i = 0; + while (i < t.size()) { + if (t[i] == kColorChar && i + 1 < t.size() && t[i + 1] == L'c') { + size_t n = i + 2 < t.size() ? 3 : 2; + out.push_back({ A_CODE, t.substr(i, n), s.color, 0 }); + i += n; + continue; + } + bool space = t[i] == L' '; + size_t j = i; + while (j < t.size() && (t[j] == L' ') == space && !(t[j] == kColorChar && j + 1 < t.size() && t[j + 1] == L'c')) j++; + out.push_back({ space ? A_SPACE : A_WORD, t.substr(i, j - i), s.color, 0 }); + i = j; + } + } +} + +struct RowBuilder { + std::vector& rows; + Row row; + int width = 0; + bool content = false; + + void Append(const std::wstring& text, int color, int link) { + if (!link && !row.empty() && !row.back().link && row.back().color == color) { + row.back().text += text; + return; + } + Seg s; + s.text = text; + s.color = color; + s.link = link; + row.push_back(s); + } + void Break(const std::wstring& carry, int carryColor) { + rows.push_back(row); + row.clear(); + width = 0; + content = false; + if (!carry.empty()) Append(carry, carryColor, 0); + } +}; + +} // namespace + +std::vector Wrap(const std::vector& segs, int maxWidth, const WidthFn& width) { + std::vector atoms; + Tokenize(segs, atoms); + std::vector rows; + RowBuilder b{ rows }; + std::wstring carry; // colour code in effect (inline codes of line-colour text) + int carryColor = -1; + for (size_t i = 0; i < atoms.size(); i++) { + Atom& a = atoms[i]; + switch (a.kind) { + case A_CODE: + b.Append(a.text, a.color, 0); + carry = a.text; + carryColor = a.color; + break; + case A_SPACE: { + if (!b.content && !rows.empty()) break; // no leading spaces on continuation rows + int w = width(a.text); + if (b.content && b.width + w > maxWidth) { + b.Break(carry, carryColor); + break; + } + b.Append(a.text, a.color, 0); + b.width += w; + break; + } + case A_WORD: + case A_LINK: { + int w = width(a.text); + if (b.width + w <= maxWidth) { + b.Append(a.text, a.color, a.link); + b.width += w; + b.content = true; + break; + } + if (b.content) { + b.Break(carry, carryColor); + i--; // retry this atom on the fresh row + break; + } + if (a.kind == A_LINK || a.text.size() <= 1) { // alone and too wide: place it anyway + b.Append(a.text, a.color, a.link); + b.width += w; + b.content = true; + break; + } + // A word wider than the row: cut at the longest prefix that fits (at least one char). + size_t n = 1; + while (n < a.text.size() && b.width + width(a.text.substr(0, n + 1)) <= maxWidth) n++; + b.Append(a.text.substr(0, n), a.color, 0); + b.content = true; + a.text.erase(0, n); + b.Break(carry, carryColor); + i--; + break; + } + } + } + rows.push_back(b.row); + return rows; +} + +void SentHistory::Add(const std::wstring& line) { + cursor = -1; + draft.clear(); + if (line.find_first_not_of(L" \t") == std::wstring::npos) return; + if (!items.empty() && items.back() == line) return; + items.push_back(line); + while (items.size() > cap) items.pop_front(); +} + +bool SentHistory::Up(const std::wstring& current, std::wstring* out) { + if (items.empty()) return false; + if (cursor < 0) { + draft = current; + cursor = (long)items.size() - 1; + } else if (cursor > 0) { + cursor--; + } else { + return false; + } + *out = items[(size_t)cursor]; + return true; +} + +bool SentHistory::Down(std::wstring* out) { + if (cursor < 0) return false; + if ((size_t)cursor + 1 < items.size()) { + cursor++; + *out = items[(size_t)cursor]; + } else { + cursor = -1; + *out = draft; + } + return true; +} + +void ScrollState::Select(int tab) { + if (tab < 0 || tab >= TAB_COUNT) return; + active = tab; + unread[tab] = 0; +} + +void ScrollState::OnLine(unsigned mask, int rows) { + for (int t = 0; t < TAB_COUNT; t++) { + if (!(mask & TabBit(t))) continue; + if (t != active) unread[t]++; + if (offset[t] > 0) { + offset[t] += rows; + newBelow[t]++; + } + } +} + +void ScrollState::Clamp(int tab, int totalRows, int visibleRows) { + int maxOff = totalRows - visibleRows; + if (maxOff < 0) maxOff = 0; + if (offset[tab] > maxOff) offset[tab] = maxOff; + if (offset[tab] < 0) offset[tab] = 0; + if (offset[tab] == 0) newBelow[tab] = 0; +} + +void ScrollState::Scroll(int delta, int totalRows, int visibleRows) { + offset[active] += delta; + Clamp(active, totalRows, visibleRows); +} + +void ScrollState::JumpToLatest() { + offset[active] = 0; + newBelow[active] = 0; +} + +void ScrollState::Reset() { + for (int t = 0; t < TAB_COUNT; t++) offset[t] = unread[t] = newBelow[t] = 0; +} + +} // namespace ChatModel diff --git a/BH/Modules/Chat/ChatModel.h b/BH/Modules/Chat/ChatModel.h new file mode 100644 index 00000000..4048588b --- /dev/null +++ b/BH/Modules/Chat/ChatModel.h @@ -0,0 +1,161 @@ +#pragma once +// Chat overhaul: the pure parts (no Windows, no game calls), unit-tested natively. +// +// Classify which kind a received chat line is, from the packet that printed it +// TabsFor which tabs show a kind +// Ring the bounded line buffer (oldest evicted first) +// IsMention your character name / configured keywords as whole words, case-insensitive +// Timestamp "[hh:mm]" +// Wrap word wrap of styled segments (links stay whole, colour codes carried over) +// SentHistory Up/Down recall of sent messages with the unsent draft kept +// ScrollState per-tab scroll position, unread badges, "jump to latest" +// +// Text is D2's: wide strings with inline colour codes "\xFF" 'c' . + +#include +#include + +#include +#include +#include +#include + +namespace ChatModel { + +enum Tab { TAB_ALL = 0, TAB_GAME, TAB_WHISPERS, TAB_SYSTEM, TAB_GLOBAL, TAB_COUNT }; +const wchar_t* TabName(int tab); +inline unsigned TabBit(int tab) { return 1u << tab; } + +enum Kind { + KIND_CHAT, // a player in this game (party members and you included) talking: Game tab + KIND_WHISPER_IN, // " whispers: ..." + KIND_WHISPER_OUT, // "You whispered to : ..." + KIND_SYSTEM, // everything else: server text, game/party events, item notifications, BH/PD2 messages +}; + +// What printed a line. Filled by the game glue from the packet being handled when the engine +// printed it; ORIGIN_NONE when no chat packet was in flight (game events, BH notifications, engine text). +enum Origin { ORIGIN_NONE, ORIGIN_CHAT_PACKET, ORIGIN_BNET_WHISPER_IN, ORIGIN_BNET_WHISPER_OUT }; + +struct Source { + Origin origin = ORIGIN_NONE; + uint8_t chatType = 0; // S->C 0x26 byte 1: 1 chat, 2 whisper from, 4 server text, 6 whisper sent + uint8_t unitType = 0; // 0x26 byte 3: 2 = named player chat +}; + +Kind Classify(const Source& s); +unsigned TabsFor(Kind k); // bit mask over Tab; always includes TAB_ALL, never TAB_GLOBAL + +// Bounded FIFO: Push evicts the oldest element once `capacity` elements are held. +template +class Ring { +public: + explicit Ring(size_t capacity) : buf(capacity ? capacity : 1) {} + size_t Size() const { return count; } + size_t Capacity() const { return buf.size(); } + uint64_t Pushed() const { return pushed; } + // i = 0 is the oldest held element. + T& At(size_t i) { return buf[(head + i) % buf.size()]; } + const T& At(size_t i) const { return buf[(head + i) % buf.size()]; } + // i = 0 is the newest. + T& FromNewest(size_t i) { return At(count - 1 - i); } + const T& FromNewest(size_t i) const { return At(count - 1 - i); } + // Returns true and moves the evicted element into *evicted (when non-null) if the ring was full. + bool Push(T value, T* evicted = nullptr) { + pushed++; + if (count < buf.size()) { + buf[(head + count) % buf.size()] = std::move(value); + count++; + return false; + } + if (evicted) *evicted = std::move(buf[head]); + buf[head] = std::move(value); + head = (head + 1) % buf.size(); + return true; + } + template + void Clear(F onEvict) { + for (size_t i = 0; i < count; i++) { + onEvict(At(i)); + At(i) = T(); + } + head = count = 0; + } + +private: + std::vector buf; + size_t head = 0, count = 0; + uint64_t pushed = 0; +}; + +const wchar_t kColorChar = 0xFF; // D2 colour code: 0xFF 'c' +// Removes every colour code (also a trailing incomplete one). +std::wstring StripColorCodes(const std::wstring& s); +// Whole-word, case-insensitive (ASCII + Latin-1 letters), colour codes ignored. Empty needle: false. +bool ContainsWord(const std::wstring& haystack, const std::wstring& needle); +bool IsMention(const std::wstring& body, const std::wstring& myName, const std::vector& keywords); + +std::wstring Timestamp(int hour, int minute); // "[hh:mm]", 24 h, values clamped + +// A run of text drawn in one style. color -1 = the line's colour (inline codes still apply); +// link 0 = plain text, otherwise an opaque handle that is never split by Wrap. +struct Seg { + std::wstring text; + int color = -1; + int link = 0; +}; +typedef std::vector Row; +typedef std::function WidthFn; // pixel width, colour codes = 0 px + +// Greedy word wrap at maxWidth px. Breaks after spaces; a word wider than a whole row is cut by +// characters; a link segment moves to the next row whole (alone on a row it may overflow). +// The colour code in effect at the end of a row is repeated at the start of the next one, and +// spaces at the start of a continuation row are dropped. Always returns at least one row. +std::vector Wrap(const std::vector& segs, int maxWidth, const WidthFn& width); + +// Sent-message recall: Up walks to older entries (the current draft is kept), Down walks back and +// finally restores the draft. Consecutive duplicates and blank lines are not stored. +class SentHistory { +public: + explicit SentHistory(size_t capacity) : cap(capacity ? capacity : 1) {} + void Add(const std::wstring& line); + bool Up(const std::wstring& current, std::wstring* out); + bool Down(std::wstring* out); + void ResetBrowse() { cursor = -1; } + bool Browsing() const { return cursor >= 0; } + size_t Size() const { return items.size(); } + const std::wstring& Newest() const { return items.back(); } + +private: + size_t cap; + std::deque items; // oldest .. newest + long cursor = -1; // index into items while browsing + std::wstring draft; +}; + +// Scroll position + unread badges for every tab. Offsets count wrapped rows up from the newest. +class ScrollState { +public: + int Active() const { return active; } + // Switch tab: clears its unread count. + void Select(int tab); + // A line with `rows` wrapped rows arrived for the tabs in `mask`. Tabs other than the active one + // count it unread; a tab that is scrolled up keeps its view still (offset grows by `rows`). + void OnLine(unsigned mask, int rows); + // Scroll the active tab by delta rows (positive = older), clamped to [0, totalRows - visibleRows]. + void Scroll(int delta, int totalRows, int visibleRows); + void JumpToLatest(); + void Clamp(int tab, int totalRows, int visibleRows); + int Offset(int tab) const { return offset[tab]; } + int Unread(int tab) const { return unread[tab]; } + int NewBelow(int tab) const { return newBelow[tab]; } // lines that arrived while scrolled up + void Reset(); + +private: + int active = TAB_ALL; + int offset[TAB_COUNT] = {}; + int unread[TAB_COUNT] = {}; + int newBelow[TAB_COUNT] = {}; +}; + +} // namespace ChatModel diff --git a/BH/Modules/Chat/ChatOverhaul.cpp b/BH/Modules/Chat/ChatOverhaul.cpp new file mode 100644 index 00000000..3c6e43b6 --- /dev/null +++ b/BH/Modules/Chat/ChatOverhaul.cpp @@ -0,0 +1,866 @@ +#include "ChatOverhaul.h" +#include "ChatModel.h" +#include "../../BH.h" +#include "../../D2Ptrs.h" +#include "../../D2Stubs.h" +#include "../../D2Helpers.h" +#include "../../Constants.h" +#include "../../D2Version.h" +#include "../../Patch.h" +#include "../ItemLinks/ChatLinkApi.h" + +#include +#include +#include + +using namespace ChatModel; + +namespace { + +// ---- 1.13c D2Client addresses (module-relative) ------------------------------------------------- +const int kPrintPartyString = 0x7D610; // void __stdcall (wchar_t* msg, int color): the party-message area +const int kPacketTable = 0xDDE60; // S->C handlers: { handler, size, 0 } per opcode, 12 bytes +const int kCallDrawChat = 0x7ECDA, kDrawChatLines = 0x7B730; // call sites in the per-frame UI draw +const int kCallDrawParty = 0x7ECDF, kDrawPartyLines = 0x7B580; +const int kChatMsg = 0x11EC80; // wchar_t[257] chat input buffer +const int kChatMsgLen = 0x11C028; // DWORD, chars in the input (append position) +const BYTE kPpsPrologue[] = { 0x81, 0xEC, 0x08, 0x04, 0x00, 0x00 }; // sub esp,408 + +const int kFont = 13; // the engine's chat font +const int kRowH = 15; // the engine's chat row height (text baseline to baseline) +const int kTabH = 18; +const int kMaxInput = 255; // engine cap on the input line + +// D2 text colours +const int kWhite = 0, kGold = 4, kGrey = 5, kYellow = 9; +// Palette indices for boxes/lines (D2GFX DrawRectangle/DrawLine) and their transparency modes +const int kBlack = 0, kLineGrey = 0x1D, kTabFill = 0x37, kMentionFill = 0x0A, kMentionBar = 0x6D; +const int kTransLight = 0, kTransHalf = 1, kOpaque = 5; + +BYTE* D2C(int off) { return (BYTE*)Patch::GetDllOffset(D2CLIENT, off); } + +bool WriteCode(BYTE* at, const BYTE* bytes, size_t n) { + DWORD old; + if (!VirtualProtect(at, n, PAGE_EXECUTE_READWRITE, &old)) return false; + memcpy(at, bytes, n); + VirtualProtect(at, n, old, &old); + FlushInstructionCache(GetCurrentProcess(), at, n); + return true; +} + +void Rel32(BYTE op, BYTE* at, const void* to, BYTE* out5) { + out5[0] = op; + *(int*)(out5 + 1) = (int)((BYTE*)to - (at + 5)); +} + +// Entry detour: jmp at the function entry to `fn`; the trampoline runs the stolen prologue (or the +// previous owner's jmp) and continues. Refuses anything but the expected bytes. +struct Detour { + BYTE* at = nullptr; + BYTE saved[8] = {}; + size_t len = 0; + BYTE* tramp = nullptr; + bool on = false; + + bool Install(int off, const BYTE* expect, size_t expectLen, void* fn) { + if (on) return true; + at = D2C(off); + if (!tramp) tramp = (BYTE*)VirtualAlloc(NULL, 32, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); + if (!at || !tramp) return false; + if (at[0] == 0xE9) { // someone else detoured it: chain through their jmp + len = 5; + BYTE* prevTarget = at + 5 + *(int*)(at + 1); + Rel32(0xE9, tramp, prevTarget, tramp); + } else if (memcmp(at, expect, expectLen) == 0) { + len = expectLen; + memcpy(tramp, at, len); + Rel32(0xE9, tramp + len, at + len, tramp + len); + } else { + return false; + } + FlushInstructionCache(GetCurrentProcess(), tramp, 32); + memcpy(saved, at, len); + BYTE code[8]; + memset(code, 0x90, sizeof(code)); + Rel32(0xE9, at, fn, code); + if (!WriteCode(at, code, len)) return false; + on = true; + return true; + } + void Remove(void* fn) { + if (!on) return; + BYTE mine[5]; + Rel32(0xE9, at, fn, mine); + if (memcmp(at, mine, 5) == 0) WriteCode(at, saved, len); // only if still ours + on = false; + } +}; + +// A verified `call rel32` whose target we replace. +struct CallSite { + BYTE* at = nullptr; + BYTE saved[5] = {}; + bool on = false; + + bool Install(int off, int expectTarget, void* fn) { + if (on) return true; + at = D2C(off); + if (!at || at[0] != 0xE8 || at + 5 + *(int*)(at + 1) != D2C(expectTarget)) return false; + memcpy(saved, at, 5); + BYTE code[5]; + Rel32(0xE8, at, fn, code); + if (!WriteCode(at, code, 5)) return false; + on = true; + return true; + } + void Remove(void* fn) { + if (!on) return; + BYTE mine[5]; + Rel32(0xE8, at, fn, mine); + if (memcmp(at, mine, 5) == 0) WriteCode(at, saved, 5); + on = false; + } +}; + +// One S->C packet handler slot; the previous handler (whoever owns it) is chained. +typedef void(__fastcall* PacketHandler)(BYTE* packet); +struct TableSlot { + DWORD* slot = nullptr; + PacketHandler prev = nullptr; + bool on = false; + + bool Install(BYTE opcode, PacketHandler fn) { + if (on) return true; + slot = (DWORD*)(D2C(kPacketTable) + opcode * 12); + if (!slot || !*slot) return false; + prev = (PacketHandler)*slot; + DWORD v = (DWORD)fn; + if (!WriteCode((BYTE*)slot, (BYTE*)&v, 4)) return false; + on = true; + return true; + } + void Remove(PacketHandler fn) { + if (!on) return; + if (*slot == (DWORD)fn) { + DWORD v = (DWORD)prev; + WriteCode((BYTE*)slot, (BYTE*)&v, 4); + } + on = false; + } +}; + +// ---- state --------------------------------------------------------------------------------------- +struct Line { + uint64_t id = 0; + std::wstring text; + int color = 0; + unsigned tabs = 0; + Kind kind = KIND_SYSTEM; + bool mention = false; + int hour = 0, minute = 0; + DWORD tick = 0; + std::vector segs; + int wrapW = -1; + std::vector rows; +}; + +// The chat packet whose handler is running (set around the engine's 0x26 handler). +struct PacketCtx { + bool active = false; + BYTE type = 0, unitType = 0; + char name[16] = {}; + char msg[256] = {}; +}; + +// A Battle.net whisper reported by the chat server, matched to the line the client prints next. +struct BnetWhisper { + bool pending = false; + bool outgoing = false; + DWORD tick = 0; + std::wstring name, text; +}; + +struct Layout { + int x0, top, w, tabBottom, bodyTop, rows, textX, tsW, wrapW, bottom; +}; + +CRITICAL_SECTION g_cs; +bool g_csInit = false; +struct Lock { + Lock() { EnterCriticalSection(&g_cs); } + ~Lock() { LeaveCriticalSection(&g_cs); } +}; + +Ring* g_lines = nullptr; +ScrollState g_scroll; +SentHistory g_sent(50); +uint64_t g_nextId = 1; +std::wstring g_lastWhisperFrom, g_lastWhisperTo; +std::vector g_keywords; +PacketCtx g_ctx; +BnetWhisper g_bnet; +uint64_t g_captured = 0, g_mentions = 0, g_sounds = 0; + +bool g_hooksOn = false; +bool g_hookError = false; +Detour g_pps; +CallSite g_drawChat, g_drawParty; +TableSlot g_slot26; +WNDPROC g_prevWndProc = nullptr; +HWND g_hwnd = NULL; +WPARAM g_swallowChar = 0; + +// Hit areas of the last drawn panel (game thread only) +bool g_panelShown = false, g_panelOpen = false; +RECT g_panelRect = {}, g_jumpRect = {}, g_tabRect[TAB_COUNT] = {}; +int g_lastVisibleRows = 1, g_lastWrapW = 100; + +typedef void(__stdcall* PrintFn)(wchar_t*, int); + +int TextWidth(const std::wstring& s) { + DWORD w = 0, file = 0; + D2WIN_GetTextWidthFileNo((wchar_t*)s.c_str(), &w, &file); + return (int)w; +} + +const std::vector& RowsOf(Line& l, int wrapW) { + if (l.wrapW != wrapW) { + l.rows = Wrap(l.segs, wrapW, TextWidth); + l.wrapW = wrapW; + } + return l.rows; +} + +std::wstring Widen(const char* s) { + wchar_t buf[300]; + int n = MultiByteToWideChar(CP_ACP, 0, s, -1, buf, 300); + return n > 0 ? std::wstring(buf) : std::wstring(); +} + +std::wstring MyName() { + UnitAny* me = D2CLIENT_GetPlayerUnit(); + if (!me || !me->pPlayerData) return L""; + return Widen(me->pPlayerData->szName); +} + +void ReleaseLine(Line& l) { + for (const Seg& s : l.segs) + if (s.link) ItemLinks::Release(s.link); + l.segs.clear(); + l.rows.clear(); +} + +std::wstring InputText() { + const wchar_t* buf = (const wchar_t*)D2C(kChatMsg); + return std::wstring(buf, wcsnlen(buf, kMaxInput)); +} + +void SetInputText(const std::wstring& s) { + wchar_t* buf = (wchar_t*)D2C(kChatMsg); + size_t n = s.size() < (size_t)kMaxInput ? s.size() : (size_t)kMaxInput; + wmemcpy(buf, s.c_str(), n); + wmemset(buf + n, 0, kMaxInput + 2 - n); // the engine appends at len without terminating + *(DWORD*)D2C(kChatMsgLen) = (DWORD)n; +} + +void PlayMentionSound() { + int sound = App.chat.mentionSound.value; + SoundsTxt* txt = *p_D2CLIENT_SoundsTxt; + if (sound <= 0 || !App.pd2.pd2PlaySoundImpl || !txt || sound >= (int)*p_D2CLIENT_SoundRecords) return; + App.pd2.pd2PlaySoundImpl(NULL, sound, txt[sound].volume, 255, FALSE); + g_sounds++; +} + +// Every line the engine prints (and every line BH prints through the engine) arrives here first. +void Capture(const wchar_t* msg, int color) { + if (!msg || !g_lines) return; + bool sound = false; + { + Lock lock; + Source src; + std::wstring body, sender; + if (g_ctx.active) { + src.origin = ORIGIN_CHAT_PACKET; + src.chatType = g_ctx.type; + src.unitType = g_ctx.unitType; + sender = Widen(g_ctx.name); + body = Widen(g_ctx.msg); + } else if (g_bnet.pending && GetTickCount() - g_bnet.tick < 3000 && + StripColorCodes(msg).find(g_bnet.text) != std::wstring::npos) { + src.origin = g_bnet.outgoing ? ORIGIN_BNET_WHISPER_OUT : ORIGIN_BNET_WHISPER_IN; + sender = g_bnet.name; + body = g_bnet.text; + g_bnet.pending = false; + } + std::wstring me = MyName(); + bool senderIsMe = !sender.empty() && _wcsicmp(sender.c_str(), me.c_str()) == 0; + Line l; + l.id = g_nextId++; + l.kind = Classify(src); + l.tabs = TabsFor(l.kind); + l.text.assign(msg, wcsnlen(msg, 1024)); + l.color = color & 0xFF; + l.mention = App.chat.mentionHighlight.value && !senderIsMe && + (l.kind == KIND_CHAT || l.kind == KIND_WHISPER_IN) && + IsMention(body, me, g_keywords); + if (l.kind == KIND_WHISPER_IN && !sender.empty()) g_lastWhisperFrom = sender; + if (l.kind == KIND_WHISPER_OUT && !sender.empty()) g_lastWhisperTo = sender; + SYSTEMTIME t; + GetLocalTime(&t); + l.hour = t.wHour; + l.minute = t.wMinute; + l.tick = GetTickCount(); + std::vector runs; // item links: "[Name]" runs in quality colour, each holds a reference + ItemLinks::FormatMessage(l.text.c_str(), runs); + for (const ChatRun& r : runs) { + Seg s; + s.text = r.text; + s.color = r.color; + s.link = r.link; + l.segs.push_back(s); + } + + DWORD oldFont = D2WIN_SetTextSize(kFont); + int rows = (int)RowsOf(l, g_lastWrapW).size(); + D2WIN_SetTextSize(oldFont); + + unsigned tabs = l.tabs; + sound = l.mention; + Line evicted; + if (g_lines->Push(std::move(l), &evicted)) ReleaseLine(evicted); + g_scroll.OnLine(tabs, rows); + g_captured++; + if (sound) g_mentions++; + } + if (sound) PlayMentionSound(); +} + +// Every PrintGameString line, as the engine stores it (after the item-link rewrite). +void __stdcall ObserveLine(const wchar_t* msg, int color) { + Capture(msg, color); +} + +void __stdcall PrintPartyString_Hook(wchar_t* msg, int color) { + Capture(msg, color); + ((PrintFn)g_pps.tramp)(msg, color); +} + +void CopyStr(char* dst, const char* src, size_t cap) { + size_t n = 0; + while (n + 1 < cap && src[n]) { + dst[n] = src[n]; + n++; + } + dst[n] = 0; +} + +// S->C 0x26: +1 type, +3 unitType, +10 name\0 msg\0 +void __fastcall Packet26_Hook(BYTE* p) { + PacketCtx saved = g_ctx; + g_ctx = PacketCtx(); + g_ctx.active = true; + g_ctx.type = p[1]; + g_ctx.unitType = p[3]; + CopyStr(g_ctx.name, (const char*)p + 10, sizeof(g_ctx.name)); + CopyStr(g_ctx.msg, (const char*)p + 10 + strnlen((const char*)p + 10, 16) + 1, sizeof(g_ctx.msg)); + g_slot26.prev(p); + g_ctx = saved; +} + +// The vanilla overlay is replaced by the panel, drawn at the same point of the frame (before BH's +// overlays, so tooltips and BH panels stay on top). The party-message area's lines are in the panel too. +void DrawChatArea(); +void __cdecl DrawChatHook() { DrawChatArea(); } +void __cdecl SkipDraw() {} + +void RemoveHooks() { + g_drawChat.Remove((void*)DrawChatHook); + g_drawParty.Remove((void*)SkipDraw); + g_slot26.Remove(Packet26_Hook); + ItemLinks::SetLineObserver(nullptr); + g_pps.Remove((void*)PrintPartyString_Hook); + g_hooksOn = false; +} + +bool InstallHooks() { + if (D2Version::GetGameVersionID() != VERSION_113c) return false; + bool ok = ItemLinks::SetLineObserver(ObserveLine) && + g_pps.Install(kPrintPartyString, kPpsPrologue, sizeof(kPpsPrologue), (void*)PrintPartyString_Hook) && + g_slot26.Install(0x26, Packet26_Hook) && + g_drawChat.Install(kCallDrawChat, kDrawChatLines, (void*)DrawChatHook) && + g_drawParty.Install(kCallDrawParty, kDrawPartyLines, (void*)SkipDraw); + if (!ok) { + RemoveHooks(); + return false; + } + g_hooksOn = true; + return true; +} + +// Called on the game thread: hooks follow the config switch. +void ApplyEnabled() { + if (App.chat.enabled.value && !g_hooksOn && !g_hookError) { + if (!InstallHooks()) g_hookError = true; // engine bytes not as expected: stay vanilla + } else if (!App.chat.enabled.value && g_hooksOn) { + RemoveHooks(); + } +} + +Layout ComputeLayout() { + Layout L; + int W = (int)*p_D2CLIENT_ScreenSizeX, H = (int)*p_D2CLIENT_ScreenSizeY; + DWORD covered = *p_D2CLIENT_ScreenCovered; + int left = covered == 2 ? W / 2 : 0; + int avail = (covered == 1 || covered == 2) ? W / 2 : W; + L.w = App.chat.width.value; + if (L.w > avail - 20) L.w = avail - 20; + L.x0 = left + 10; + L.top = D2CLIENT_GetUIState(UI_ESCMENU_EX) ? 81 : 6; // the engine's own chat starts at y 20 / 95 + L.tabBottom = L.top + kTabH; + L.bodyTop = L.tabBottom + 3; + int maxRows = (H - 150 - L.bodyTop) / kRowH; + L.rows = App.chat.visibleRows.value < maxRows ? App.chat.visibleRows.value : maxRows; + if (L.rows < 1) L.rows = 1; + L.bottom = L.bodyTop + L.rows * kRowH + 4; + L.textX = L.x0 + 6; + L.tsW = App.chat.timestamps.value ? TextWidth(L"[00:00] ") : 0; + L.wrapW = L.w - 12 - L.tsW; + if (L.wrapW < 40) L.wrapW = 40; + return L; +} + +bool InRect(const RECT& r, int x, int y) { return x >= r.left && x < r.right && y >= r.top && y < r.bottom; } + +// Item-link runs drawn this frame (screen boxes) and in the last finished frame (click targets). +struct LinkHit { + RECT r; + int link; +}; +std::vector g_hitsCur, g_hitsLast; + +void DrawRow(const Line& l, const Row& row, bool first, int x, int y, int tsW) { + if (tsW && first) D2WIN_DrawText(Timestamp(l.hour, l.minute).c_str(), x, y, kGrey, 0); + x += tsW; + for (const Seg& s : row) { + D2WIN_DrawText(s.text.c_str(), x, y, s.color < 0 ? l.color : s.color, 0); + int w = TextWidth(s.text); + if (s.link && g_hitsCur.size() < 256) g_hitsCur.push_back({ { x, y - 14, x + w, y + 2 }, s.link }); + x += w; + } +} + +int TotalRows(int tab, int wrapW) { + int n = 0; + for (size_t i = 0; i < g_lines->Size(); i++) { + Line& l = g_lines->At(i); + if (l.tabs & TabBit(tab)) n += (int)RowsOf(l, wrapW).size(); + } + return n; +} + +struct VisRow { + Line* line; + size_t row; +}; + +void DrawPanel(const Layout& L) { + int right = L.x0 + L.w; + D2GFX_DrawRectangle(L.x0, L.top, right, L.bottom, kBlack, kTransHalf); + D2GFX_DrawLine(L.x0, L.tabBottom, right, L.tabBottom, kLineGrey, 0xFF); + + // Tabs + int x = L.x0 + 2; + for (int t = 0; t < TAB_COUNT; t++) { + std::wstring label = TabName(t); + int n = g_scroll.Unread(t); + if (n > 0) label += L" \xFF" L"c8(" + std::to_wstring(n > 99 ? 99 : n) + L")"; + int w = TextWidth(label) + 10; + bool active = g_scroll.Active() == t; + if (active) D2GFX_DrawRectangle(x, L.top + 1, x + w, L.tabBottom, kTabFill, kTransHalf); + D2WIN_DrawText(label.c_str(), x + 5, L.top + 15, active ? kGold : (t == TAB_GLOBAL ? kGrey : kWhite), 0); + g_tabRect[t] = { x, L.top, x + w, L.tabBottom }; + x += w + 2; + } + + g_jumpRect = {}; + int active = g_scroll.Active(); + if (active == TAB_GLOBAL) { + D2WIN_DrawText(L"Global chat: coming soon", L.textX, L.bodyTop + 14, kGold, 0); + D2WIN_DrawText(L"Realm-wide chat needs PD2 server support.", L.textX, L.bodyTop + 14 + kRowH, kGrey, 0); + return; + } + + // Rows of the active tab, newest first, skipping the scroll offset. + std::vector vis; + int skip = g_scroll.Offset(active); + for (size_t i = 0; i < g_lines->Size() && (int)vis.size() < L.rows; i++) { + Line& l = g_lines->FromNewest(i); + if (!(l.tabs & TabBit(active))) continue; + const std::vector& rows = RowsOf(l, L.wrapW); + for (size_t r = rows.size(); r-- > 0 && (int)vis.size() < L.rows;) { + if (skip > 0) { + skip--; + continue; + } + vis.push_back({ &l, r }); + } + } + if (vis.empty()) { + D2WIN_DrawText(L"No messages yet.", L.textX, L.bodyTop + 14, kGrey, 0); + return; + } + for (size_t i = 0; i < vis.size(); i++) { + int y = L.bodyTop + 14 + (L.rows - 1 - (int)i) * kRowH; + Line& l = *vis[i].line; + if (l.mention) { + D2GFX_DrawRectangle(L.x0 + 2, y - 13, right - 2, y + 2, kMentionFill, kTransLight); + D2GFX_DrawRectangle(L.x0 + 2, y - 13, L.x0 + 4, y + 2, kMentionBar, kOpaque); + } + DrawRow(l, l.rows[vis[i].row], vis[i].row == 0, L.textX, y, L.tsW); + } + + if (g_scroll.Offset(active) > 0) { + int nb = g_scroll.NewBelow(active); + std::wstring label = nb > 0 ? L"Jump to latest (" + std::to_wstring(nb) + L" new)" : L"Jump to latest"; + int w = TextWidth(label) + 10; + int y = L.bottom - 4; + g_jumpRect = { right - w - 4, y - 14, right - 4, y + 2 }; + D2GFX_DrawRectangle(g_jumpRect.left, g_jumpRect.top, g_jumpRect.right, g_jumpRect.bottom, kBlack, kOpaque); + D2WIN_DrawText(label.c_str(), g_jumpRect.left + 5, y, kYellow, 0); + } +} + +// Closed panel: the recent lines of the active tab, like the engine's own fading chat. +void DrawFaded(const Layout& L) { + int active = g_scroll.Active(); + if (active == TAB_GLOBAL || App.chat.fadeRows.value <= 0) return; + DWORD now = GetTickCount(), life = (DWORD)App.chat.fadeSeconds.value * 1000; + std::vector vis; + for (size_t i = 0; i < g_lines->Size() && (int)vis.size() < App.chat.fadeRows.value; i++) { + Line& l = g_lines->FromNewest(i); + if (now - l.tick > life) break; + if (!(l.tabs & TabBit(active))) continue; + const std::vector& rows = RowsOf(l, L.wrapW); + for (size_t r = rows.size(); r-- > 0 && (int)vis.size() < App.chat.fadeRows.value;) vis.push_back({ &l, r }); + } + int y = L.top + 14; + for (size_t i = vis.size(); i-- > 0; y += kRowH) { + Line& l = *vis[i].line; + const Row& row = l.rows[vis[i].row]; + int w = L.tsW; + for (const Seg& s : row) w += TextWidth(s.text); + D2GFX_DrawRectangle(L.textX - 4, y - 14, L.textX + w + 4, y + 2, l.mention ? kMentionFill : kBlack, kTransHalf); + DrawRow(l, row, vis[i].row == 0, L.textX, y, L.tsW); + } +} + +void ScrollBy(int delta) { + int active = g_scroll.Active(); + if (active == TAB_GLOBAL) return; + DWORD oldFont = D2WIN_SetTextSize(kFont); + g_scroll.Scroll(delta, TotalRows(active, g_lastWrapW), g_lastVisibleRows); + D2WIN_SetTextSize(oldFont); +} + +void Reply() { + std::wstring target = !g_lastWhisperFrom.empty() ? g_lastWhisperFrom : g_lastWhisperTo; + if (target.empty()) return; + if (!D2CLIENT_GetUIState(UI_CHAT_CONSOLE)) D2CLIENT_SetUIVar(UI_CHAT_CONSOLE, 0, 0); // opening clears the input + std::wstring rest = InputText(); + if (rest.compare(0, 3, L"/w ") == 0 || rest.compare(0, 3, L"/r ") == 0) { // replace an earlier target + size_t sp = rest.find(L' ', 3); + rest = sp == std::wstring::npos ? L"" : rest.substr(sp + 1); + } + SetInputText(L"/w " + target + L" " + rest); +} + +// Window messages the panel owns. Returns true when consumed. +bool HandleMessage(UINT m, WPARAM wp) { + bool chatOpen = D2CLIENT_GetUIState(UI_CHAT_CONSOLE) != 0; + if (m == WM_CHAR) { + WPARAM vk = g_swallowChar; // the key we handled: drop the character it produces + g_swallowChar = 0; + if (vk && (wp == vk || (vk >= 'A' && vk <= 'Z' && (wp == vk - 'A' + 1 || wp == vk + 32)))) return true; + if (chatOpen && wp == VK_RETURN) { + Lock lock; + g_sent.Add(InputText()); + } else if (chatOpen && wp == VK_ESCAPE) { + Lock lock; + g_sent.ResetBrowse(); + } + return false; + } + if (m == WM_KEYDOWN) { + bool ctrl = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; + if (ctrl && App.chat.replyHotkey.hotkey && wp == App.chat.replyHotkey.hotkey) { + Lock lock; + Reply(); + g_swallowChar = wp; + return true; + } + if (!chatOpen) return false; + Lock lock; + std::wstring out; + switch (wp) { + case VK_UP: + if (g_sent.Up(InputText(), &out)) SetInputText(out); + return true; + case VK_DOWN: + if (g_sent.Down(&out)) SetInputText(out); + return true; + case VK_PRIOR: + ScrollBy(g_lastVisibleRows - 1); + return true; + case VK_NEXT: + ScrollBy(-(g_lastVisibleRows - 1)); + return true; + case VK_HOME: + ScrollBy(1 << 20); + return true; + case VK_END: + g_scroll.JumpToLatest(); + return true; + case VK_TAB: + if (!ctrl) return false; + g_scroll.Select((g_scroll.Active() + (shift ? TAB_COUNT - 1 : 1)) % TAB_COUNT); + g_swallowChar = '\t'; + return true; + } + return false; + } + if (m == WM_MOUSEWHEEL) { + int x = (int)*p_D2CLIENT_MouseX, y = (int)*p_D2CLIENT_MouseY; + if (!g_panelShown || !g_panelOpen || !InRect(g_panelRect, x, y)) return false; + int notches = GET_WHEEL_DELTA_WPARAM(wp) / WHEEL_DELTA; + if (notches == 0) notches = GET_WHEEL_DELTA_WPARAM(wp) > 0 ? 1 : -1; + Lock lock; + ScrollBy(notches * 3); + return true; + } + return false; +} + +void Say(const std::wstring& s) { D2CLIENT_PrintGameString((wchar_t*)s.c_str(), kWhite); } + +// Panel draw cost since the last ".chat status" (QueryPerformanceCounter ticks). +LONGLONG g_qpf = 0, g_drawTicks = 0, g_drawMax = 0; +unsigned g_draws = 0; + +// .chat on | off | ts | clear | status | tab +void RunCommand(const std::wstring& arg) { + const std::wstring tag = L"\xFF" L"c4Chat:\xFF" L"c0 "; + if (arg == L"on" || arg == L"off") { + App.chat.enabled.value = arg == L"on"; + g_hookError = false; + ApplyEnabled(); + Say(tag + (App.chat.enabled.value ? (g_hooksOn ? L"panel on" : L"\xFF" L"c1could not hook the engine, vanilla chat kept") : L"vanilla chat")); + return; + } + if (arg == L"ts") { + App.chat.timestamps.value = !App.chat.timestamps.value; + Say(tag + L"timestamps " + (App.chat.timestamps.value ? L"on" : L"off")); + return; + } + if (arg == L"clear") { + Lock lock; + g_lines->Clear(ReleaseLine); + g_scroll.Reset(); + return; + } + if (arg.compare(0, 4, L"tab ") == 0) { + Lock lock; + for (int t = 0; t < TAB_COUNT; t++) + if (_wcsicmp(arg.c_str() + 4, TabName(t)) == 0) g_scroll.Select(t); + return; + } + if (arg == L"status") { + wchar_t b[300]; + { + Lock lock; + double us = g_qpf ? 1e6 / (double)g_qpf : 0; + swprintf_s(b, L"%lshooks %ls, mentions %llu, sounds %llu, lines %u/%u, captured %llu, draw avg %.0fus max %.0fus over %u frames", + tag.c_str(), g_hooksOn ? L"on" : (g_hookError ? L"FAILED" : L"off"), + (unsigned long long)g_mentions, (unsigned long long)g_sounds, (unsigned)g_lines->Size(), (unsigned)g_lines->Capacity(), + (unsigned long long)g_captured, g_draws ? (double)g_drawTicks * us / g_draws : 0.0, (double)g_drawMax * us, g_draws); + g_drawTicks = g_drawMax = 0; + g_draws = 0; + } + Say(b); + return; + } + Say(tag + L".chat on | off | ts | clear | status | tab "); +} + +// ".chat ..." typed in the chat box is handled here on Enter, so it works whoever owns the engine's +// input path (in PD2 BH's own dot-commands never reach BH). +bool TryChatCommand() { + std::wstring in = InputText(); + size_t b = in.find_first_not_of(L' '); + if (b == std::wstring::npos) return false; + std::wstring cmd = in.substr(b); + if (_wcsnicmp(cmd.c_str(), L".chat", 5) != 0 || (cmd.size() > 5 && cmd[5] != L' ')) return false; + size_t a = cmd.find_first_not_of(L' ', 5); + std::wstring arg = a == std::wstring::npos ? L"" : cmd.substr(a); + while (!arg.empty() && arg.back() == L' ') arg.pop_back(); + { + Lock lock; + g_sent.Add(in); + } + SetInputText(L""); + D2CLIENT_SetUIVar(UI_CHAT_CONSOLE, 1, 0); + RunCommand(arg); + return true; +} + +LRESULT CALLBACK ChatWndProc(HWND h, UINT m, WPARAM wp, LPARAM lp) { + if (D2CLIENT_GetPlayerUnit()) { + if (m == WM_CHAR && wp == VK_RETURN && D2CLIENT_GetUIState(UI_CHAT_CONSOLE) && TryChatCommand()) return 0; + if (g_hooksOn && HandleMessage(m, wp)) return 0; + } + return CallWindowProcA(g_prevWndProc, h, m, wp, lp); +} + +} // namespace + +void ChatOverhaul::OnLoad() { + if (!g_csInit) { + InitializeCriticalSection(&g_cs); + g_csInit = true; + } + LoadConfig(); +} + +void ChatOverhaul::OnUnload() { + if (g_hooksOn) RemoveHooks(); + if (g_prevWndProc && g_hwnd && (WNDPROC)GetWindowLong(g_hwnd, GWL_WNDPROC) == ChatWndProc) + SetWindowLong(g_hwnd, GWL_WNDPROC, (LONG)g_prevWndProc); +} + +void ChatOverhaul::LoadConfig() { + Lock lock; + g_keywords.clear(); + for (const std::string& k : App.chat.mentionKeywords.values) g_keywords.push_back(Widen(k.c_str())); + size_t cap = (size_t)App.chat.historyLines.value; + if (!g_lines || g_lines->Capacity() != cap) { + Ring* fresh = new Ring(cap); + if (g_lines) { + size_t n = g_lines->Size(), keep = n < cap ? n : cap; + for (size_t i = 0; i < n; i++) { + if (i < n - keep) ReleaseLine(g_lines->At(i)); + else fresh->Push(std::move(g_lines->At(i))); + } + delete g_lines; + } + g_lines = fresh; + } + g_hookError = false; // a reload retries a failed install +} + +void ChatOverhaul::OnLoop() { + HWND h = D2GFX_GetHwnd(); + if (h && !g_prevWndProc) { + g_hwnd = h; + g_prevWndProc = (WNDPROC)SetWindowLong(h, GWL_WNDPROC, (LONG)ChatWndProc); + } + ApplyEnabled(); +} + +void ChatOverhaul::OnGameJoin() {} + +void ChatOverhaul::OnGameExit() { + Lock lock; + g_scroll.Reset(); + g_sent.ResetBrowse(); +} + +namespace { +bool g_drewThisFrame = false; + +// Game thread, from the engine's UI draw (where DrawChatLines ran; skipped while the message log is open). +void DrawChatArea() { + if (!g_lines) return; + bool chatOpen = D2CLIENT_GetUIState(UI_CHAT_CONSOLE) != 0; + Lock lock; + LARGE_INTEGER t0, t1; + QueryPerformanceCounter(&t0); + if (!chatOpen) g_sent.ResetBrowse(); + DWORD oldFont = D2WIN_SetTextSize(kFont); + Layout L = ComputeLayout(); + g_lastWrapW = L.wrapW; + g_lastVisibleRows = L.rows; + g_panelOpen = chatOpen || App.chat.alwaysOpen.value; + if (g_panelOpen) { + g_scroll.Clamp(g_scroll.Active(), TotalRows(g_scroll.Active(), L.wrapW), L.rows); + DrawPanel(L); + g_panelRect = { L.x0, L.top, L.x0 + L.w, L.bottom }; + } else { + DrawFaded(L); + g_panelRect = {}; + } + g_drewThisFrame = true; + D2WIN_SetTextSize(oldFont); + QueryPerformanceCounter(&t1); + if (!g_qpf) { + LARGE_INTEGER f; + QueryPerformanceFrequency(&f); + g_qpf = f.QuadPart; + } + LONGLONG dt = t1.QuadPart - t0.QuadPart; + g_drawTicks += dt; + if (dt > g_drawMax) g_drawMax = dt; + g_draws++; +} + +} // namespace + +void ChatOverhaul::OnDraw() { + ApplyEnabled(); + // BH draws after the engine's UI: publish what the panel drew this frame for clicks/wheel. + g_panelShown = g_hooksOn && g_drewThisFrame; + g_drewThisFrame = false; + g_hitsLast.swap(g_hitsCur); + g_hitsCur.clear(); +} + +void ChatOverhaul::OnLeftClick(bool up, int x, int y, bool* block) { + if (!g_hooksOn || !g_panelShown) return; + for (const LinkHit& h : g_hitsLast) // item links, open panel or recent lines + if (InRect(h.r, x, y)) { + *block = true; + if (!up) ItemLinks::OnClick(h.link, (h.r.left + h.r.right) / 2, h.r.top, h.r.bottom); + return; + } + if (!g_panelOpen || !InRect(g_panelRect, x, y)) return; + *block = true; + if (up) return; + Lock lock; + if (InRect(g_jumpRect, x, y)) { + g_scroll.JumpToLatest(); + return; + } + for (int t = 0; t < TAB_COUNT; t++) + if (InRect(g_tabRect[t], x, y)) g_scroll.Select(t); +} + +// Battle.net SID_CHATEVENT (0x0F): event 4 = whisper from, 0x0A = whisper to; name at 28, text after. +void ChatOverhaul::OnChatPacketRecv(BYTE* packet, bool* block) { + if (!g_hooksOn || packet[1] != 0x0F) return; + DWORD ev = *(DWORD*)&packet[4]; + if (ev != 4 && ev != 0x0A) return; + const char* name = (const char*)&packet[28]; + const char* text = name + strlen(name) + 1; + Lock lock; + g_bnet.pending = true; + g_bnet.outgoing = ev == 0x0A; + g_bnet.tick = GetTickCount(); + g_bnet.name = Widen(name); + g_bnet.text = Widen(text); + if (!g_bnet.outgoing) g_lastWhisperFrom = g_bnet.name; + else g_lastWhisperTo = g_bnet.name; +} + +void ChatOverhaul::OnUserInput(const wchar_t* msg, bool fromGame, bool* block) { + *block = true; + RunCommand(msg ? msg : L""); +} diff --git a/BH/Modules/Chat/ChatOverhaul.h b/BH/Modules/Chat/ChatOverhaul.h new file mode 100644 index 00000000..a5a9e623 --- /dev/null +++ b/BH/Modules/Chat/ChatOverhaul.h @@ -0,0 +1,32 @@ +#pragma once +// Chat overhaul: a tabbed in-game chat panel (All / Game / Whispers / System / Global placeholder) +// with scrollback, timestamps, mention highlight, sent-message history and a reply hotkey. +// Client-only. With "chat.enabled" off nothing is hooked and the vanilla chat is untouched. +// +// Hooks (1.13c D2Client, all verified byte-for-byte before patching, all removed when disabled): +// ItemLinks line observer (PrintGameString 0x7D850) and the -> every line also goes to our buffer +// PrintPartyString 0x7D610 entry +// (the engine's own lists, message +// log and side effects still run) +// S->C packet table entry 0x26 (chat) -> which packet printed a line +// call DrawChatLines 0x7ECDA / call DrawPartyLines 0x7ECDF -> the vanilla overlay is not drawn +// game window subclass -> wheel, PgUp/PgDn/End, Up/Down, +// Ctrl+Tab, Ctrl+ +#include +#include "../Module.h" + +class ChatOverhaul : public Module { +public: + ChatOverhaul() : Module("chat") {}; + + void OnLoad(); + void OnUnload(); + void LoadConfig(); + void OnLoop(); + void OnGameJoin(); + void OnGameExit(); + void OnDraw(); + void OnLeftClick(bool up, int x, int y, bool* block); + void OnChatPacketRecv(BYTE* packet, bool* block); + void OnUserInput(const wchar_t* msg, bool fromGame, bool* block); +}; diff --git a/BH/Modules/ItemLinks/ChatLinkApi.h b/BH/Modules/ItemLinks/ChatLinkApi.h new file mode 100644 index 00000000..c5d5cf8a --- /dev/null +++ b/BH/Modules/ItemLinks/ChatLinkApi.h @@ -0,0 +1,42 @@ +#pragma once +// Chat item links: the API other chat UIs use to show and open item links. +// +// The ItemLinks module already renders links in the game's own chat (overlay and message log). A +// replacement chat UI that stores and draws messages itself calls these instead: +// - FormatMessage once per received message body: plain text and link runs (validated tokens +// become "[Item Name]" in the item's quality colour; invalid tokens stay plain text); +// - OnClick when the player left-clicks a link run: opens the game's own item tooltip; +// - Release when the message leaves its buffer. +#include +#include + +struct ChatRun { + std::wstring text; + int color; // D2 text colour index, or -1 = the line's own colour + int link; // 0 = plain text, else a link handle (> 0) +}; + +namespace ItemLinks { + +// Split a message body into runs. Accepts raw "#i" words and text the game-chat path has +// already rewritten (its zero-width link markers); a "[typed name] " right before a token is folded +// into the link run. Each link run holds one reference on its handle. +void FormatMessage(const wchar_t* msg, std::vector& out); + +// Drop one reference (0 / unknown handles are ignored). The item data is freed with the last one. +void Release(int link); + +// Open the native item tooltip for `link`, anchored on the clicked run: x = run centre, yTop/yBottom +// = the run's box. The ItemLinks module draws it every frame until Esc, another click, or leaving +// the game. Returns true when the click was consumed. +bool OnClick(int link, int x, int yTop, int yBottom); + +// A chat UI that keeps its own copy of every line: `cb` sees every line the game prints through +// PrintGameString (links or not) as the game will store it, i.e. after the link rewrite (pass that +// text to FormatMessage). Game thread. nullptr clears it. While an observer is set the +// PrintGameString hook stays installed even when item links are disabled. Returns false when the +// hook can't be installed (unexpected code at the function entry). +typedef void(__stdcall* ChatLineObserver)(const wchar_t* text, int color); +bool SetLineObserver(ChatLineObserver cb); + +} // namespace ItemLinks diff --git a/BH/Modules/ItemLinks/ItemLinkCodec.cpp b/BH/Modules/ItemLinks/ItemLinkCodec.cpp new file mode 100644 index 00000000..4d41e490 --- /dev/null +++ b/BH/Modules/ItemLinks/ItemLinkCodec.cpp @@ -0,0 +1,751 @@ +#include "ItemLinkCodec.h" + +#include +#include + +#include "../../D2DataTables.h" + +namespace ItemLinkCodec { + +namespace { + +// Item flags (ItemData.dwFlags) as stored in the record. +const uint32_t F_IDENTIFIED = 0x00000010; +const uint32_t F_SOCKETED = 0x00000800; +const uint32_t F_NEW = 0x00002000; +const uint32_t F_EAR = 0x00010000; +const uint32_t F_STARTER = 0x00020000; +const uint32_t F_COMPACT = 0x00200000; +const uint32_t F_ETHEREAL = 0x00400000; +const uint32_t F_LOD = 0x00800000; +const uint32_t F_PERSONALIZED = 0x01000000; +const uint32_t F_RUNEWORD = 0x04000000; +// Everything a view-only copy keeps; all other bits are runtime state and are cleared. +const uint32_t F_KEEP = F_IDENTIFIED | F_SOCKETED | F_COMPACT | F_ETHEREAL | F_LOD | F_PERSONALIZED | F_RUNEWORD; + +const unsigned STAT_ID_BITS = 10; // item version 103 +const unsigned STAT_TERMINATOR = 0x3FF; +const unsigned STAT_ARMORCLASS = 31; +const unsigned STAT_DURABILITY = 72; +const unsigned STAT_MAXDURABILITY = 73; +const unsigned STAT_STATE = 98; +const unsigned STAT_NUMSOCKETS = 194; + +const unsigned LIST_RUNEWORD = 6; +const int MAX_TYPE_DEPTH = 8; + +// ---- bits --------------------------------------------------------------------------------------- + +struct Reader { + const uint8_t* p; + size_t bits; + size_t pos; + bool overrun; + + Reader(const uint8_t* data, size_t len) : p(data), bits(len * 8), pos(0), overrun(false) {} + + // LSB-first, like the engine's bit buffer. Never reads past `bits`; an overrun sets the flag + // and yields 0 for the rest of the parse. + uint32_t Read(unsigned n) { + if (n == 0) + return 0; + if (n > 32 || overrun || bits - pos < n) { + overrun = true; + return 0; + } + uint32_t v = 0; + for (unsigned i = 0; i < n; ++i, ++pos) + v |= (uint32_t)((p[pos >> 3] >> (pos & 7)) & 1) << i; + return v; + } +}; + +struct Writer { + uint8_t* p; + size_t cap; + size_t pos; + bool full; + + Writer(uint8_t* out, size_t capBytes) : p(out), cap(capBytes * 8), pos(0), full(false) { + if (capBytes) + memset(out, 0, capBytes); + } + void Put(unsigned n, uint32_t v) { + if (n == 0) + return; + if (full || cap - pos < n) { + full = true; + return; + } + for (unsigned i = 0; i < n; ++i, ++pos) + if ((v >> i) & 1) + p[pos >> 3] |= (uint8_t)(1u << (pos & 7)); + } + size_t Bytes() const { return (pos + 7) / 8; } +}; + +// ---- tables --------------------------------------------------------------------------------------- + +const ItemStatCostTxt* Isc(const Tables& t, unsigned id) { + if (!t.isc || id >= t.iscCount) + return 0; + return (const ItemStatCostTxt*)((const uint8_t*)t.isc + (size_t)id * t.iscStride); +} + +const ItemsTxt* ItemAt(const Tables& t, unsigned i) { + return (const ItemsTxt*)((const uint8_t*)t.items + (size_t)i * t.itemsStride); +} + +const D2ItemTypesTxt* TypeAt(const Tables& t, unsigned i) { + if (!t.itemTypes || i >= t.itemTypesCount) + return 0; + return (const D2ItemTypesTxt*)((const uint8_t*)t.itemTypes + (size_t)i * t.itemTypesStride); +} + +bool TypeIs(const Tables& t, unsigned type, const char code[4], int depth) { + if (depth > MAX_TYPE_DEPTH || type == 0) + return false; + const D2ItemTypesTxt* tt = TypeAt(t, type); + if (!tt) + return false; + if (memcmp(tt->szCode, code, 4) == 0) + return true; + return TypeIs(t, tt->nEquiv1, code, depth + 1) || TypeIs(t, tt->nEquiv2, code, depth + 1); +} + +bool ItemIs(const Tables& t, const ItemsTxt* it, const char code[4]) { + return TypeIs(t, it->nType, code, 0) || TypeIs(t, it->wtype2, code, 0); +} + +bool FindItem(const Tables& t, const char code[4], unsigned* index) { + if (!t.items) + return false; + for (unsigned i = 0; i < t.itemsCount; ++i) // first row wins, like D2Common's code lookup + if (memcmp(ItemAt(t, i)->szCode, code, 4) == 0) { + *index = i; + return true; + } + return false; +} + +bool SkillOk(const Tables& t, unsigned skill) { + // Only the bound: real items name skills without a SkillDesc row (PD2's splash-on-hit skill), + // and the engine's description copes with them. + return skill < t.skillsCount; +} + +struct Width { + unsigned bits, paramBits; + const ItemStatCostTxt* row; +}; + +bool StatWidth(const Tables& t, unsigned id, Width* w) { + const ItemStatCostTxt* r = Isc(t, id); + if (!r) + return false; + w->bits = r->bSaveBits; + w->paramBits = r->dwSaveParamBits; + w->row = r; + // Save Bits 0 is legal: the engine writes such ids (e.g. poison_count after poison damage) and + // reads no value bits for them. + return w->bits <= 32 && w->paramBits <= 32; +} + +// A stat parameter that the item description resolves through another table must name a row that +// exists there (by ItemStatCost descfunc / encode: skills, classes, skill tabs, monsters, states). +bool ParamOk(const Tables& t, unsigned id, const ItemStatCostTxt* r, uint32_t param) { + if (r->dwSaveParamBits == 0) + return true; + if (id == STAT_STATE) + return param < t.statesCount; + if (r->bEncode == 2 || r->bEncode == 3) // (skill << 6) | level: chance-to-cast, charges + return SkillOk(t, param >> 6); + switch (r->bDescFunc) { + case 13: return param < t.classCount; // +N to skills + case 14: return (param >> 3) < t.classCount && (param & 7) < 3; // +N to skills + case 15: case 24: return SkillOk(t, param >> 6); + case 16: case 27: case 28: return SkillOk(t, param); // aura / single skill / oskill + case 22: return param < t.monTypeCount; // vs monster type + case 23: return param < t.monStatsCount; // reanimate as + default: return true; + } +} + +// Grouped damage stats: the stream stores the head id and then the follower values. +int Followers(unsigned id, unsigned out[2]) { + switch (id) { + case 17: out[0] = 18; return 1; + case 48: out[0] = 49; return 1; + case 50: out[0] = 51; return 1; + case 52: out[0] = 53; return 1; + case 54: out[0] = 55; out[1] = 56; return 2; + case 57: out[0] = 58; out[1] = 59; return 2; + default: return 0; + } +} + +Result ReadStatList(const Tables& t, Reader& r, Item* it, uint8_t list) { + int last = -1; + for (;;) { + unsigned id = r.Read(STAT_ID_BITS); + if (r.overrun) + return ERR_TRUNCATED; + if (id == STAT_TERMINATOR) + return OK; + if (id == 0 && last == 0) + return ERR_STAT; // the engine treats two strength entries in a row as corrupt + last = (int)id; + if (it->nstats >= kMaxStats) + return ERR_STAT; + Stat& s = it->stats[it->nstats]; + memset(&s, 0, sizeof s); + s.id = (uint16_t)id; + s.list = list; + unsigned ids[3] = { id, 0, 0 }; + int n = 1 + Followers(id, ids + 1); + for (int k = 0; k < n; ++k) { + Width w; + if (!StatWidth(t, ids[k], &w)) + return ERR_STAT; + s.param[k] = r.Read(w.paramBits); + s.raw[k] = r.Read(w.bits); + if (r.overrun) + return ERR_TRUNCATED; + if (!ParamOk(t, ids[k], w.row, s.param[k])) + return ERR_STAT_PARAM; + } + s.nvals = (uint8_t)n; + ++it->nstats; + } +} + +bool NameCharOk(unsigned c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '-' || c == '_'; +} + +} // namespace + +const char* ResultName(Result r) { + switch (r) { + case OK: return "ok"; + case ERR_EMPTY: return "empty"; + case ERR_TOO_LONG: return "too long"; + case ERR_BASE64: return "bad base64"; + case ERR_TRUNCATED: return "truncated"; + case ERR_TRAILING: return "trailing bytes"; + case ERR_VERSION: return "item version"; + case ERR_EAR: return "ear"; + case ERR_CODE: return "item code"; + case ERR_QUALITY: return "quality"; + case ERR_AFFIX: return "affix"; + case ERR_FILEINDEX: return "unique/set id"; + case ERR_RUNEWORD: return "runeword"; + case ERR_NAME: return "personalized name"; + case ERR_REALM: return "realm data"; + case ERR_SOCKETS: return "sockets"; + case ERR_STAT: return "stat"; + case ERR_STAT_PARAM: return "stat parameter"; + case ERR_CHILD: return "socketed item"; + case ERR_TABLES: return "tables unavailable"; + } + return "?"; +} + +// ---- base64url ------------------------------------------------------------------------------------ + +static const char kB64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +bool IsBase64UrlChar(unsigned c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'; +} + +static int B64Value(unsigned c) { + if (c >= 'A' && c <= 'Z') return (int)(c - 'A'); + if (c >= 'a' && c <= 'z') return (int)(c - 'a') + 26; + if (c >= '0' && c <= '9') return (int)(c - '0') + 52; + if (c == '-') return 62; + if (c == '_') return 63; + return -1; +} + +size_t Base64UrlEncode(const uint8_t* data, size_t len, char* out, size_t cap) { + size_t need = (len * 4 + 2) / 3; + if (need > cap) + return 0; + size_t o = 0; + for (size_t i = 0; i < len; i += 3) { + uint32_t v = (uint32_t)data[i] << 16; + size_t n = len - i; + if (n > 1) v |= (uint32_t)data[i + 1] << 8; + if (n > 2) v |= data[i + 2]; + out[o++] = kB64[(v >> 18) & 63]; + out[o++] = kB64[(v >> 12) & 63]; + if (n > 1) out[o++] = kB64[(v >> 6) & 63]; + if (n > 2) out[o++] = kB64[v & 63]; + } + if (o < cap) + out[o] = 0; + return o; +} + +bool Base64UrlDecode(const char* text, size_t len, uint8_t* out, size_t cap, size_t* outLen) { + if (len % 4 == 1) + return false; + size_t need = len * 3 / 4; + if (need > cap) + return false; + size_t o = 0; + uint32_t acc = 0; + unsigned nacc = 0; + for (size_t i = 0; i < len; ++i) { + int v = B64Value((unsigned char)text[i]); + if (v < 0) + return false; + acc = (acc << 6) | (uint32_t)v; + nacc += 6; + if (nacc >= 8) { + nacc -= 8; + out[o++] = (uint8_t)(acc >> nacc); + acc &= (1u << nacc) - 1; + } + } + if (acc != 0) // non-canonical: leftover bits must be zero + return false; + *outLen = o; + return true; +} + +// ---- parse ---------------------------------------------------------------------------------------- + +Result ParseRecord(const Tables& t, const uint8_t* data, size_t len, bool withMagic, Item* it, size_t* consumedBytes) { + if (!t.isc || !t.items || !t.itemTypes) + return ERR_TABLES; + memset(it, 0, sizeof *it); + Reader r(data, len); + if (withMagic && r.Read(16) != 0x4D4A) + return r.overrun ? ERR_TRUNCATED : ERR_VERSION; + it->flags = r.Read(32); + it->version = (uint16_t)r.Read(10); + if (r.overrun) + return ERR_TRUNCATED; + if (it->version != kItemVersion) + return ERR_VERSION; + if (it->flags & F_EAR) + return ERR_EAR; + it->mode = (uint8_t)r.Read(3); + if (it->mode == 3 || it->mode == 5) { + it->x = (uint16_t)r.Read(16); + it->y = (uint16_t)r.Read(16); + } else { + it->bodyloc = (uint8_t)r.Read(4); + it->col = (uint8_t)r.Read(4); + it->row = (uint8_t)r.Read(4); + it->page = (uint8_t)r.Read(3); + } + for (int i = 0; i < 4; ++i) + it->code[i] = (char)r.Read(8); + if (r.overrun) + return ERR_TRUNCATED; + if (!FindItem(t, it->code, &it->itemIndex)) + return ERR_CODE; + const ItemsTxt* base = ItemAt(t, it->itemIndex); + if (ItemIs(t, base, "gold") || ItemIs(t, base, "book")) + return ERR_CODE; // their records carry extra fields this codec doesn't model + it->isWeapon = it->itemIndex < t.weaponsCount; + it->isArmor = !it->isWeapon && it->itemIndex < t.weaponsCount + t.armorCount; + it->stackable = base->bstackable != 0; + + if (it->flags & F_COMPACT) { + it->compact = true; + it->filled = (uint8_t)r.Read(1); + if (r.overrun) + return ERR_TRUNCATED; + if (it->filled) + return ERR_SOCKETS; + *consumedBytes = (r.pos + 7) / 8; + return OK; + } + + it->filled = (uint8_t)r.Read(3); + it->seed = r.Read(32); + it->ilvl = (uint8_t)r.Read(7); + it->quality = (uint8_t)r.Read(4); + if (r.Read(1)) { + it->hasGfx = true; + it->gfx = (uint8_t)r.Read(3); + if (it->gfx >= 6) + return ERR_CODE; + } + if (r.Read(1)) { + it->hasAuto = true; + it->autoMagic = (uint16_t)r.Read(11); + if (it->autoMagic > t.autoMagicCount) + return ERR_AFFIX; + } + if (r.overrun) + return ERR_TRUNCATED; + switch (it->quality) { + case 1: // low + it->fileIndex = (uint16_t)r.Read(3); + if (it->fileIndex >= t.lowQualityCount) + return ERR_FILEINDEX; + break; + case 2: // normal + break; + case 3: // superior + it->fileIndex = (uint16_t)r.Read(3); + if (it->fileIndex >= t.qualityItemsCount) + return ERR_FILEINDEX; + break; + case 4: // magic + it->magicPrefix = (uint16_t)r.Read(11); + it->magicSuffix = (uint16_t)r.Read(11); + if (it->magicPrefix > t.magicPrefixCount || it->magicSuffix > t.magicSuffixCount) + return ERR_AFFIX; + break; + case 5: // set + case 7: // unique + it->fileIndex = (uint16_t)r.Read(12); + if (it->fileIndex >= (it->quality == 5 ? t.setItemCount : t.uniqueCount)) + return ERR_FILEINDEX; + break; + case 6: // rare + case 8: // crafted + it->rareName1 = (uint8_t)r.Read(8); + it->rareName2 = (uint8_t)r.Read(8); + // One table: rare suffixes, then rare prefixes, addressed from 1. An unidentified rare may + // carry no name (0/0); the description never names it. + if ((it->flags & F_IDENTIFIED) || it->rareName1 || it->rareName2) { + if (it->rareName1 <= t.rareSuffixCount || it->rareName1 > t.rareSuffixCount + t.rarePrefixCount) + return ERR_AFFIX; + if (it->rareName2 == 0 || it->rareName2 > t.rareSuffixCount) + return ERR_AFFIX; + } + for (int k = 0; k < 6; ++k) { + it->rareHas[k] = r.Read(1) != 0; + if (it->rareHas[k]) { + it->rareAffix[k] = (uint16_t)r.Read(11); + unsigned limit = (k & 1) ? t.magicSuffixCount : t.magicPrefixCount; + if (it->rareAffix[k] > limit) + return ERR_AFFIX; + } + } + break; + default: + return ERR_QUALITY; + } + if (it->flags & F_RUNEWORD) { + it->runeword = (uint16_t)r.Read(16); + if ((unsigned)(it->runeword & 0xFFF) >= t.runesCount) + return ERR_RUNEWORD; + } + if (it->flags & F_PERSONALIZED) { + for (;;) { + unsigned c = r.Read(7); + if (r.overrun) + return ERR_TRUNCATED; + if (c == 0) + break; + if (it->nameLen >= kMaxNameChars || !NameCharOk(c)) + return ERR_NAME; + it->name[it->nameLen++] = (char)c; + } + if (it->nameLen == 0) + return ERR_NAME; + } + if (r.Read(1)) + return r.overrun ? ERR_TRUNCATED : ERR_REALM; + if (it->isArmor) { + Width w; + if (!StatWidth(t, STAT_ARMORCLASS, &w)) + return ERR_STAT; + it->defenseRaw = r.Read(w.bits); + } + if (it->isArmor || it->isWeapon) { + Width w; + if (!StatWidth(t, STAT_MAXDURABILITY, &w)) + return ERR_STAT; + it->maxDurRaw = r.Read(w.bits); + if (it->maxDurRaw != 0) { + if (!StatWidth(t, STAT_DURABILITY, &w)) + return ERR_STAT; + it->curDurRaw = r.Read(w.bits); + } + } + if (it->stackable) + it->quantity = (uint16_t)r.Read(9); + if (it->flags & F_SOCKETED) { + Width w; + if (!StatWidth(t, STAT_NUMSOCKETS, &w)) + return ERR_STAT; + it->socketsRaw = r.Read(w.bits); + if (it->socketsRaw > (unsigned)kMaxChildren) + return ERR_SOCKETS; + } + if (r.overrun) + return ERR_TRUNCATED; + if (it->filled > (it->flags & F_SOCKETED ? it->socketsRaw : 0)) + return ERR_SOCKETS; + if (it->quality == 5) + it->setMask = (uint8_t)r.Read(5); + + Result res = ReadStatList(t, r, it, 0); + if (res != OK) + return res; + for (unsigned k = 0; k < 5; ++k) + if (it->setMask & (1u << k)) { + res = ReadStatList(t, r, it, (uint8_t)(1 + k)); + if (res != OK) + return res; + } + if (it->flags & F_RUNEWORD) { + res = ReadStatList(t, r, it, (uint8_t)LIST_RUNEWORD); + if (res != OK) + return res; + } + *consumedBytes = (r.pos + 7) / 8; + return OK; +} + +static Result ParseRecords(const Tables& t, const uint8_t* data, size_t len, bool withMagic, Link* out) { + memset(out, 0, sizeof *out); + if (len == 0) + return ERR_EMPTY; + if (len > kMaxPayload + (withMagic ? 2 * (1 + kMaxChildren) : 0)) + return ERR_TOO_LONG; + size_t used = 0; + Result res = ParseRecord(t, data, len, withMagic, &out->items[0], &used); + if (res != OK) + return res; + out->count = 1; + const Item& parent = out->items[0]; + if (parent.filled && !(parent.isArmor || parent.isWeapon)) + return ERR_CHILD; + for (int k = 0; k < parent.filled; ++k) { + if (used >= len) + return ERR_CHILD; + size_t n = 0; + Item* child = &out->items[out->count]; + res = ParseRecord(t, data + used, len - used, withMagic, child, &n); + if (res != OK) + return res; + used += n; + ++out->count; + const ItemsTxt* cb = ItemAt(t, child->itemIndex); + if (!ItemIs(t, cb, "sock") || child->filled || (child->flags & (F_SOCKETED | F_RUNEWORD))) + return ERR_CHILD; + } + if (used != len) + return ERR_TRAILING; + return OK; +} + +Result ParsePayload(const Tables& t, const uint8_t* data, size_t len, Link* out) { + return ParseRecords(t, data, len, false, out); +} + +Result ParseSaveRecords(const Tables& t, const uint8_t* data, size_t len, Link* out) { + return ParseRecords(t, data, len, true, out); +} + +Result ParseToken(const Tables& t, const char* b64, size_t len, Link* out) { + if (len == 0) + return ERR_EMPTY; + if (len > kMaxTokenChars) + return ERR_TOO_LONG; + uint8_t buf[kMaxPayload]; + size_t n = 0; + if (!Base64UrlDecode(b64, len, buf, sizeof buf, &n)) + return ERR_BASE64; + return ParsePayload(t, buf, n, out); +} + +void Canonicalize(Link* link) { + for (int i = 0; i < link->count; ++i) { + Item& it = link->items[i]; + it.flags &= F_KEEP; + it.flags |= F_LOD; + it.seed = 0; + it.x = it.y = 0; + if (i == 0) { // stored, inventory page + it.mode = 0; + it.bodyloc = 0; + it.col = 0; + it.row = 0; + it.page = 1; + } else { // in the parent's socket i-1 + it.mode = 6; + it.bodyloc = 0; + it.col = (uint8_t)(i - 1); + it.row = 0; + it.page = 0; + } + } +} + +// ---- encode --------------------------------------------------------------------------------------- + +static void PutStatList(const Tables& t, Writer& w, const Item& it, uint8_t list) { + for (int i = 0; i < it.nstats; ++i) { + const Stat& s = it.stats[i]; + if (s.list != list) + continue; + w.Put(STAT_ID_BITS, s.id); + unsigned ids[3] = { s.id, 0, 0 }; + Followers(s.id, ids + 1); + for (int k = 0; k < s.nvals; ++k) { + Width wd; + if (!StatWidth(t, ids[k], &wd)) { + w.full = true; + return; + } + w.Put(wd.paramBits, s.param[k]); + w.Put(wd.bits, s.raw[k]); + } + } + w.Put(STAT_ID_BITS, STAT_TERMINATOR); +} + +size_t EncodeRecord(const Tables& t, const Item& it, bool withMagic, uint8_t* out, size_t cap) { + Writer w(out, cap); + if (withMagic) + w.Put(16, 0x4D4A); + w.Put(32, it.flags); + w.Put(10, it.version); + w.Put(3, it.mode); + if (it.mode == 3 || it.mode == 5) { + w.Put(16, it.x); + w.Put(16, it.y); + } else { + w.Put(4, it.bodyloc); + w.Put(4, it.col); + w.Put(4, it.row); + w.Put(3, it.page); + } + for (int i = 0; i < 4; ++i) + w.Put(8, (uint8_t)it.code[i]); + if (it.compact) { + w.Put(1, it.filled); + return w.full ? 0 : w.Bytes(); + } + w.Put(3, it.filled); + w.Put(32, it.seed); + w.Put(7, it.ilvl); + w.Put(4, it.quality); + w.Put(1, it.hasGfx); + if (it.hasGfx) + w.Put(3, it.gfx); + w.Put(1, it.hasAuto); + if (it.hasAuto) + w.Put(11, it.autoMagic); + switch (it.quality) { + case 1: + case 3: + w.Put(3, it.fileIndex); + break; + case 4: + w.Put(11, it.magicPrefix); + w.Put(11, it.magicSuffix); + break; + case 5: + case 7: + w.Put(12, it.fileIndex); + break; + case 6: + case 8: + w.Put(8, it.rareName1); + w.Put(8, it.rareName2); + for (int k = 0; k < 6; ++k) { + w.Put(1, it.rareHas[k]); + if (it.rareHas[k]) + w.Put(11, it.rareAffix[k]); + } + break; + default: + break; + } + if (it.flags & F_RUNEWORD) + w.Put(16, it.runeword); + if (it.flags & F_PERSONALIZED) { + for (int i = 0; i < it.nameLen; ++i) + w.Put(7, (uint8_t)it.name[i]); + w.Put(7, 0); + } + w.Put(1, 0); // no realm data + Width wd; + if (it.isArmor) { + if (!StatWidth(t, STAT_ARMORCLASS, &wd)) + return 0; + w.Put(wd.bits, it.defenseRaw); + } + if (it.isArmor || it.isWeapon) { + if (!StatWidth(t, STAT_MAXDURABILITY, &wd)) + return 0; + w.Put(wd.bits, it.maxDurRaw); + if (it.maxDurRaw != 0) { + if (!StatWidth(t, STAT_DURABILITY, &wd)) + return 0; + w.Put(wd.bits, it.curDurRaw); + } + } + if (it.stackable) + w.Put(9, it.quantity); + if (it.flags & F_SOCKETED) { + if (!StatWidth(t, STAT_NUMSOCKETS, &wd)) + return 0; + w.Put(wd.bits, it.socketsRaw); + } + if (it.quality == 5) + w.Put(5, it.setMask); + PutStatList(t, w, it, 0); + for (unsigned k = 0; k < 5; ++k) + if (it.setMask & (1u << k)) + PutStatList(t, w, it, (uint8_t)(1 + k)); + if (it.flags & F_RUNEWORD) + PutStatList(t, w, it, (uint8_t)LIST_RUNEWORD); + return w.full ? 0 : w.Bytes(); +} + +size_t EncodePayload(const Tables& t, const Link& link, uint8_t* out, size_t cap) { + size_t used = 0; + for (int i = 0; i < link.count; ++i) { + size_t n = EncodeRecord(t, link.items[i], false, out + used, cap - used); + if (n == 0) + return 0; + used += n; + } + return used; +} + +size_t EncodeToken(const Tables& t, const Link& link, char* out, size_t cap) { + uint8_t buf[kMaxPayload]; + size_t n = EncodePayload(t, link, buf, sizeof buf); + if (n == 0) + return 0; + return Base64UrlEncode(buf, n, out, cap); +} + +// ---- token text ----------------------------------------------------------------------------------- + +bool FindToken(const wchar_t* s, size_t len, size_t from, size_t* start, size_t* end) { + for (size_t i = from; i + 2 < len; ++i) { + if (s[i] != L'#' || s[i + 1] != L'i') + continue; + if (i > 0 && s[i - 1] != L' ') + continue; + size_t j = i + 2; + while (j < len && j - (i + 2) <= kMaxTokenChars && IsBase64UrlChar(s[j])) + ++j; + size_t n = j - (i + 2); + if (n < kMinTokenChars || n > kMaxTokenChars) + continue; + if (j < len && s[j] != L' ') + continue; + *start = i; + *end = j; + return true; + } + return false; +} + +} // namespace ItemLinkCodec diff --git a/BH/Modules/ItemLinks/ItemLinkCodec.h b/BH/Modules/ItemLinks/ItemLinkCodec.h new file mode 100644 index 00000000..97625a4c --- /dev/null +++ b/BH/Modules/ItemLinks/ItemLinkCodec.h @@ -0,0 +1,164 @@ +#pragma once +// Chat item links: the token codec and validator (pure logic, no game calls). +// +// Token text: "#i" + base64url (alphabet A-Z a-z 0-9 - _, no padding) of a payload. +// Payload: the item's save-format record WITHOUT the 2-byte "JM" magic, followed by the save +// records of its socketed children (gems/runes/jewels) in socket order, exactly how a +// character file stores them. Every record is byte-aligned; the parent's 3-bit +// "filled sockets" field says how many child records follow. +// +// A token comes from anyone in chat, so the receiver never hands it to the engine as-is: +// 1. ParsePayload walks the item grammar with a bounds-checked bit reader, caps every count and +// checks every table id (item code, qualities, affixes, unique/set/runeword ids, stat ids and +// their parameters) against the game's own tables. +// 2. Canonicalize clears what a view-only copy must not carry (location, seed, runtime flags). +// 3. EncodeRecord writes the canonical record back. The engine only ever decodes bytes that this +// encoder produced from validated fields. +// Item version 103 (PD2's live format: 10-bit stat ids) only. + +#include +#include + +struct ItemStatCostTxt; +struct ItemsTxt; +struct D2ItemTypesTxt; + +namespace ItemLinkCodec { + +const uint16_t kItemVersion = 103; +// A chat message is at most 255 characters (the engine's input box, C->S 0x15 and S->C 0x26 caps), +// so a token can't be longer than 255 - "#i" anyway; 186 bytes = 248 base64 characters. +const size_t kMaxPayload = 186; // bytes after base64 decoding +const size_t kMaxTokenChars = (kMaxPayload * 4 + 2) / 3; // base64 characters after "#i" +const size_t kMinTokenChars = 16; +const int kMaxChildren = 6; +const int kMaxStats = 80; // stat entries (head + followers) per item, all lists together +const int kMaxNameChars = 15; // personalized name + +enum Result { + OK = 0, + ERR_EMPTY, + ERR_TOO_LONG, + ERR_BASE64, + ERR_TRUNCATED, // the grammar ran past the end of the payload + ERR_TRAILING, // bytes left after the last record + ERR_VERSION, + ERR_EAR, + ERR_CODE, // unknown item code or a kind we refuse (gold, books) + ERR_QUALITY, + ERR_AFFIX, + ERR_FILEINDEX, + ERR_RUNEWORD, + ERR_NAME, + ERR_REALM, + ERR_SOCKETS, + ERR_STAT, // unknown stat id, or too many stats + ERR_STAT_PARAM, // a stat parameter that names a skill/class/monster/state that doesn't exist + ERR_CHILD, // a child that is not a socket filler, or a parent/child count mismatch + ERR_TABLES, // tables not available +}; +const char* ResultName(Result r); + +// The game's tables the validator checks ids against (filled from D2Common's data tables in game, +// from the PD2 fixture tables in the native tests). Counts are record counts; a null pointer with +// a zero count means "not available" and fails validation (ERR_TABLES) where it is needed. +struct Tables { + const ItemStatCostTxt* isc; + unsigned iscCount; + size_t iscStride; // sizeof one ItemStatCost record in this game build + const ItemsTxt* items; + unsigned itemsCount; + size_t itemsStride; + unsigned weaponsCount, armorCount; // items = weapons, then armor, then misc + const D2ItemTypesTxt* itemTypes; + unsigned itemTypesCount; + size_t itemTypesStride; + unsigned skillsCount; + unsigned magicPrefixCount, magicSuffixCount, autoMagicCount; + unsigned rarePrefixCount, rareSuffixCount; + unsigned uniqueCount, setItemCount, runesCount; + unsigned lowQualityCount, qualityItemsCount; + unsigned classCount, monStatsCount, monTypeCount, statesCount; +}; + +// One stat entry as stored: a head id with its value (and the follower values of grouped damage +// stats, which the stream writes without their own ids). `raw` is the stored bit pattern. +struct Stat { + uint16_t id; + uint8_t list; // 0 = own list, 1..5 = set bonus lists, 6 = runeword list + uint8_t nvals; // 1 + followers + uint32_t param[3]; + uint32_t raw[3]; +}; + +struct Item { + uint32_t flags; + uint16_t version; + uint8_t mode, bodyloc, col, row, page; + uint16_t x, y; + char code[4]; + unsigned itemIndex; // index into Tables::items + bool compact; + uint8_t filled; // filled sockets (children that follow) + uint32_t seed; + uint8_t ilvl, quality; + bool hasGfx; + uint8_t gfx; + bool hasAuto; + uint16_t autoMagic; + uint16_t fileIndex; + uint16_t magicPrefix, magicSuffix; + uint8_t rareName1, rareName2; + bool rareHas[6]; + uint16_t rareAffix[6]; // prefix1, suffix1, prefix2, suffix2, prefix3, suffix3 + uint16_t runeword; + char name[kMaxNameChars + 1]; + uint8_t nameLen; + bool isArmor, isWeapon, stackable; + uint32_t defenseRaw, maxDurRaw, curDurRaw; + uint16_t quantity; + uint32_t socketsRaw; + uint8_t setMask; + Stat stats[kMaxStats]; + int nstats; +}; + +struct Link { + Item items[1 + kMaxChildren]; + int count; // parent + children +}; + +// Base64url without padding. Encode returns the number of chars written (0 if `cap` is too small; +// a terminating NUL is written when there is room). Decode rejects any other character, padding, +// and an impossible length (len % 4 == 1). +size_t Base64UrlEncode(const uint8_t* data, size_t len, char* out, size_t cap); +bool Base64UrlDecode(const char* text, size_t len, uint8_t* out, size_t cap, size_t* outLen); +bool IsBase64UrlChar(unsigned c); + +// One record. `withMagic`: data starts with "JM". On success *consumedBytes is the record's +// byte length (rounded up to a whole byte, as records are stored). +Result ParseRecord(const Tables& t, const uint8_t* data, size_t len, bool withMagic, Item* out, size_t* consumedBytes); +// A whole token payload: parent + exactly `filled` socket fillers, no trailing bytes. +Result ParsePayload(const Tables& t, const uint8_t* data, size_t len, Link* out); +// The engine's save-form serialization of an item with its socketed children (every record starts +// with "JM", as in a character file): the sender's input. No trailing bytes either. +Result ParseSaveRecords(const Tables& t, const uint8_t* data, size_t len, Link* out); +// Token text (the characters after "#i") -> validated link. +Result ParseToken(const Tables& t, const char* b64, size_t len, Link* out); + +// Clear location, seed and every flag outside the allowlist; children get the socket location. +void Canonicalize(Link* link); + +// Write one record (0 = didn't fit `cap`). Never fails otherwise for an Item ParseRecord accepted. +size_t EncodeRecord(const Tables& t, const Item& it, bool withMagic, uint8_t* out, size_t cap); +// Parent + children back to a payload (no magic) / to token text (without the "#i" marker). +size_t EncodePayload(const Tables& t, const Link& link, uint8_t* out, size_t cap); +size_t EncodeToken(const Tables& t, const Link& link, char* out, size_t cap); + +// Find the next "#i" token in a wide string, starting at `from`. The token must start +// the string or follow a space and end at a space/end of string; its length must be within +// [kMinTokenChars, kMaxTokenChars]. Returns false if none. [*start, *end) covers "#i..." and +// [*start + 2, *end) is the base64 text. +bool FindToken(const wchar_t* s, size_t len, size_t from, size_t* start, size_t* end); + +} // namespace ItemLinkCodec diff --git a/BH/Modules/ItemLinks/ItemLinkGame.cpp b/BH/Modules/ItemLinks/ItemLinkGame.cpp new file mode 100644 index 00000000..ddffd48d --- /dev/null +++ b/BH/Modules/ItemLinks/ItemLinkGame.cpp @@ -0,0 +1,574 @@ +#include "ItemLinkGame.h" + +#include +#include + +#include "../../D2Ptrs.h" +#include "../../D2Structs.h" +#include "../../D2DataTables.h" +#include "../../D2Version.h" +#include "../../Constants.h" +#include "../Item/Item.h" + +// Addresses are Diablo II 1.13c (D2Client/D2Common/D2Win RVAs and D2Common ordinals). PD2's +// D2Client/D2Common are byte-identical to retail 1.13c; Init() checks the code bytes anyway. + +namespace ItemLinkGame { + +namespace { + +// ---- engine entry points ------------------------------------------------------------------------- + +typedef DWORD(__stdcall* DecodeItem_t)(UnitAny* item, BYTE* bits, DWORD size, BOOL saveForm, int* socketed, DWORD version, BOOL* fail); +typedef DWORD(__stdcall* SaveItem_t)(UnitAny* item, BYTE* buf, DWORD size, BOOL saveForm, BOOL withChildren, BOOL gamble); +typedef UnitAny*(__stdcall* AllocUnit_t)(void* pool, DWORD type); +typedef void(__fastcall* InitSeed_t)(void* seed); +typedef void(__stdcall* AllocItemData_t)(void* pool, UnitAny* item); +typedef void(__stdcall* UnitOnly_t)(UnitAny* item); +typedef BOOL(__stdcall* PlaceInSocket_t)(Inventory* inv, UnitAny* item, int unused); +typedef UnitAny*(__stdcall* InvRemove_t)(Inventory* inv, UnitAny* item); +typedef void(__stdcall* Unmerge_t)(UnitAny* owner, UnitAny* item); +typedef UnitAny*(__stdcall* InvFirst_t)(Inventory* inv); +typedef UnitAny*(__stdcall* CursorItem_t)(Inventory* inv); +typedef BOOL(__stdcall* GetItemName_t)(UnitAny* item, wchar_t* buf, DWORD size); +typedef void(__fastcall* ClearHover_t)(UnitAny* item); +typedef void(__stdcall* DrawHoverPopup_t)(UnitAny* owner, BOOL reqVsPlayer); +typedef void(__fastcall* SetHoverPopup_t)(const wchar_t* text, int x, int y, DWORD color, BOOL center); +typedef void(__stdcall* RenderHoverPopup_t)(void); +typedef DWORD(__fastcall* TextNWidth_t)(const wchar_t* s, int n); +typedef DWORD(__fastcall* SetFont_t)(DWORD font); + +DecodeItem_t pDecodeItem; +SaveItem_t pSaveItem; +AllocUnit_t pAllocUnit; +InitSeed_t pInitSeed; +AllocItemData_t pAllocItemData; +UnitOnly_t pInitItemDataSeed, pAllocStaticPath, pFreeClientUnit; +PlaceInSocket_t pPlaceInSocket; +InvRemove_t pInvRemove; +Unmerge_t pUnmerge; +InvFirst_t pInvFirst; +CursorItem_t pCursorItem; +GetItemName_t pGetItemName; +ClearHover_t pClearHover; +DrawHoverPopup_t pDrawHoverPopup; +SetHoverPopup_t pSetHoverPopup; +RenderHoverPopup_t pRenderHoverPopup; +TextNWidth_t pTextNWidth; +SetFont_t pSetFont; +DWORD aItemInitMode, aAllocUnitGfx, aLinkItemStats; // D2Client usercall helpers + +// D2Client globals +UnitAny** gSelectedInvItem; +BOOL *gHoverGrid, *gHoverBody; +int *gAboveX, *gAboveY, *gBelowX, *gBelowY; +DWORD* gShopCursorType; +DWORD* gExpCharFlag; +wchar_t* gChatMsg; +DWORD* gChatMsgLen; +wchar_t* gHoverPopupText; // D2Win, wchar_t[0x400]: non-empty = something staged a popup this frame +DWORD* gHoverPopup; // D2Win, after the text: the staged x, y, colour, centre flag + +bool ready = false; +DWORD nextId = 0; + +const DWORD D2CLIENT_ITEM_INIT_MODE = 0x82820; // eax=mode, esi=item, edi=room; push x, y, bInit; ret 0xC +const DWORD D2CLIENT_ALLOC_UNIT_GFX = 0x66950; // esi=unit +const DWORD D2CLIENT_LINK_ITEM_STATS = 0x81FF0; // edi=owner, esi=item; push bForce; ret 4 +const DWORD ITEM_VERSION_ARG = 0x60; // what the client passes to the decoder +const DWORD TRANSIENT_ID_BASE = 0xFFF00000; // ids the server never hands out +const int MAX_SAVE = 0x400; + +struct Site { + Dll dll; + int offset; // RVA, or -ordinal + const char* bytes; + const char* name; +}; + +// Code we call; the first bytes of each, from the 1.13c binaries (PD2's are byte-identical). +const Site kSites[] = { + { D2COMMON, -11145, "8b 44 24 0c 8b 4c 24 08 83 ec 14 53", "D2Common#11145 DecodeItem" }, + { D2COMMON, -10987, "8b 44 24 0c 8b 4c 24 08 83 ec 14 50", "D2Common#10987 SaveItem" }, + { D2COMMON, -11153, "53 8b 5c 24 08 56 57 6a 00 68 6e 06", "D2Common#11153 AllocUnit" }, + { D2COMMON, -10936, "c7 01 01 00 00 00 c7 41 04 9a 02 00", "D2Common#10936 InitSeed" }, + { D2COMMON, -10874, "56 8b 74 24 0c 85 f6 74 5b 83 3e 04", "D2Common#10874 AllocItemData" }, + { D2COMMON, -10679, "8b 44 24 04 85 c0 74 1a 83 38 04 75", "D2Common#10679 InitItemDataSeed" }, + { D2COMMON, -10445, "56 8b 74 24 08 8b 46 2c 85 c0 75 35", "D2Common#10445 AllocStaticPath" }, + { D2COMMON, -10405, "56 8b 74 24 08 85 f6 74 08 81 3e 04", "D2Common#10405 PlaceItemInSocket" }, + { D2COMMON, -10646, "8b 44 24 04 85 c0 75 03 c2 08 00 56", "D2Common#10646 InvRemoveItem" }, + { D2COMMON, -11127, "8b 44 24 08 85 c0 74 0d 8b 40 5c 85", "D2Common#11127 UnmergeItemStats" }, + { D2COMMON, -10460, "8b 44 24 04 85 c0 74 08 81 38 04 03", "D2Common#10460 FirstInventoryItem" }, + { D2CLIENT, 0x82820, "53 8b d8 83 fb 07 55 8b 6c 24 10 0f", "D2Client ItemInitMode" }, + { D2CLIENT, 0x66950, "e8 3b 20 00 00 85 c0 89 46 54 75 04", "D2Client AllocUnitGfx" }, + { D2CLIENT, 0x81FF0, "85 ff 0f 84 50 01 00 00 53 6a 14 56", "D2Client LinkItemStats" }, + { D2CLIENT, 0xA6D60, "55 8b 6c 24 08 56 8b 75 64 57 33 ff", "D2Client FreeClientUnit" }, + { D2CLIENT, 0x8D370, "a1 38 bc bc 6f 3b c1 75 37 83 c8 ff", "D2Client ClearHoverItem" }, + { D2CLIENT, 0x93450, "b8 50 66 00 00 e8 06 4a f7 ff 53 55", "D2Client DrawItemHoverPopup" }, + { D2WIN, -10085, "53 56 8b f1 85 f6 8b da 74 1c ff 15", "D2Win#10085 SetHoverPopup" }, + { D2WIN, -10110, "83 ec 1c a1 34 fd 8f 6f b9 58 9e 9a", "D2Win#10110 RenderHoverPopup" }, + { D2WIN, -10171, "51 55 56 57 33 ff 33 ed 66 39 39 89", "D2Win#10171 TextNWidth" }, + { D2WIN, -10177, "56 8b f1 57 8b c6 8b fa e8 a3 f8 ff", "D2Win#10177 GetTextSize" }, +}; + +int HexByte(const char* p) { + int v = 0; + for (int i = 0; i < 2; ++i) { + char c = p[i]; + v = v * 16 + (c >= 'a' ? c - 'a' + 10 : c - '0'); + } + return v; +} + +// Compare code bytes. A few prologues embed absolute addresses of D2Client/D2Win globals at the +// modules' preferred bases; a relocated module fails the check and the feature stays off. +// A function another module has detoured (`jmp rel32`, int3 padding up to the next whole +// instruction, the rest untouched: D2GL does this to SetHoverPopup and GetTextSize for its HD +// text and tooltips) still matches when everything after the padding does; we only call these, +// so going through the detour is what the game itself does. +bool CodeMatches(DWORD addr, const char* hex) { + if (!addr) + return false; + BYTE want[32]; + int n = 0; + while (*hex && n < (int)sizeof want) { + want[n++] = (BYTE)HexByte(hex); + hex += 2; + while (*hex == ' ') + ++hex; + } + const BYTE* p = (const BYTE*)addr; + int i = 0; + if (p[0] == 0xE9 && want[0] != 0xE9) { + i = 5; + while (i < n && p[i] == 0xCC && want[i] != 0xCC) + ++i; + if (n - i < 4) + return false; // too little left to recognise the function + } + for (; i < n; ++i) + if (p[i] != want[i]) + return false; + return true; +} + +DWORD Addr(Dll dll, int offset) { + return (DWORD)Patch::GetDllOffset(dll, offset); +} + +template +T Fn(Dll dll, int offset) { + return (T)Addr(dll, offset); +} + +// Register-convention D2Client helpers. Naked __fastcall stubs: the caller then treats eax/ecx/edx +// as clobbered by the call, which an inline __asm block calling the engine does not guarantee +// (clang-cl kept a live pointer in edx across such a block). +__declspec(naked) void __fastcall ItemInitMode(UnitAny* item) { + __asm { + push esi + push edi + push 1 // bInit + push 0 // y + push 0 // x + mov esi, ecx + xor edi, edi // no room + xor eax, eax // mode 0 + call aItemInitMode + pop edi + pop esi + ret + } +} + +__declspec(naked) void __fastcall AllocUnitGfx(UnitAny* item) { + __asm { + push esi + mov esi, ecx + call aAllocUnitGfx + pop esi + ret + } +} + +__declspec(naked) void __fastcall LinkItemStats(UnitAny* owner, UnitAny* item) { + __asm { + push esi + push edi + push 0 // bForce + mov edi, ecx + mov esi, edx + call aLinkItemStats + pop edi + pop esi + ret + } +} + +// One record (save form, "JM" first) -> a bare item unit; nothing links it anywhere. +UnitAny* BuildOne(const ItemLinkCodec::Item& it, BYTE* rec, DWORD len, int* socketed) { + UnitAny* u = pAllocUnit(NULL, UNIT_ITEM); + if (!u) + return NULL; + u->dwTxtFileNo = it.itemIndex; + u->dwUnitId = TRANSIENT_ID_BASE | (nextId++ & 0xFFFFF); + if (*gExpCharFlag) + u->dwFlags2 |= 0x2000000; + else + u->dwFlags2 &= ~0x2000000; + pInitSeed(&u->dwSeed[0]); + pAllocItemData(NULL, u); + pInitItemDataSeed(u); + pAllocStaticPath(u); + ItemInitMode(u); + AllocUnitGfx(u); + BOOL fail = TRUE; + int nsock = 0; + DWORD used = pDecodeItem(u, rec, len, TRUE, &nsock, ITEM_VERSION_ARG, &fail); + *socketed = nsock; + if (fail || used != len) { + FreeItem(u); + return NULL; + } + return u; +} + +// PD2 draws the inventory hover with its own popup builder, which leaves the "Quantity: N" line +// out for stacks of an equippable item type (ItemTypes Body set: javelins, throwing knives and +// axes); keys and other non-equippable stacks keep it. The link tooltip runs the game's builder, +// so drop that line the same way, then stage the popup where the builder would have put the +// shorter text (above the anchor if it fits, else below). +void DropEquippableQuantity(UnitAny* item, int yTop, int yBottom) { + sgptDataTable* g = *p_D2COMMON_sgptDataTable; + int type = D2COMMON_GetItemType(item); + if (!g || !g->pItemsTypeTxt || type < 0 || (DWORD)type >= g->dwItemsTypeRecs || !g->pItemsTypeTxt[type].nBody) + return; + // The builder's line: GetString(3462) GetString(0xF9B) "%ld" GetString(0xF9E), colour codes first. + wchar_t want[64]; + const wchar_t* label = D2LANG_GetLocaleText(3462); + const wchar_t* sep = D2LANG_GetLocaleText(0xF9B); + if (!label || !sep || swprintf_s(want, 64, L"%s%s%ld", label, sep, (long)D2COMMON_GetUnitStat(item, STAT_AMMOQUANTITY, 0)) < 0) + return; + size_t wantLen = wcslen(want); + wchar_t* text = gHoverPopupText; + for (wchar_t* line = text; *line;) { + wchar_t* end = wcschr(line, L'\n'); + size_t len = end ? (size_t)(end - line) : wcslen(line); + const wchar_t* s = line; + while (s + 3 <= line + len && s[0] == 0xFF && s[1] == L'c') + s += 3; + if ((size_t)(line + len - s) == wantLen && !wcsncmp(s, want, wantLen)) { + wchar_t* from = end ? end + 1 : line + len; + if (!end && line > text) + --line; // last line: drop the newline before it instead + memmove(line, from, (wcslen(from) + 1) * sizeof(wchar_t)); + wchar_t restage[0x400]; + wcscpy_s(restage, 0x400, text); + DWORD w = 0, h = 0; + D2WIN_GetTextSize(restage, &w, &h); + bool above = yTop - (int)h > 0; + pSetHoverPopup(restage, above ? *gAboveX : *gBelowX, above ? yTop : yBottom + (int)h, gHoverPopup[2], gHoverPopup[3]); + return; + } + if (!end) + break; + line = end + 1; + } +} + +} // namespace + +bool Init(const char** why) { + *why = ""; + if (D2Version::GetGameVersionID() != VERSION_113c) { + *why = "game version is not 1.13c"; + return false; + } + for (const Site& s : kSites) { + if (!CodeMatches(Addr(s.dll, s.offset), s.bytes)) { + *why = s.name; + return false; + } + } + pDecodeItem = Fn(D2COMMON, -11145); + pSaveItem = Fn(D2COMMON, -10987); + pAllocUnit = Fn(D2COMMON, -11153); + pInitSeed = Fn(D2COMMON, -10936); + pAllocItemData = Fn(D2COMMON, -10874); + pInitItemDataSeed = Fn(D2COMMON, -10679); + pAllocStaticPath = Fn(D2COMMON, -10445); + pPlaceInSocket = Fn(D2COMMON, -10405); + pInvRemove = Fn(D2COMMON, -10646); + pUnmerge = Fn(D2COMMON, -11127); + pInvFirst = Fn(D2COMMON, -10460); + pCursorItem = Fn(D2COMMON, -11017); + pFreeClientUnit = Fn(D2CLIENT, 0xA6D60); + pClearHover = Fn(D2CLIENT, 0x8D370); + pGetItemName = Fn(D2CLIENT, 0x914F0); + pDrawHoverPopup = Fn(D2CLIENT, 0x93450); + pSetHoverPopup = Fn(D2WIN, -10085); + pRenderHoverPopup = Fn(D2WIN, -10110); + pTextNWidth = Fn(D2WIN, -10171); + pSetFont = Fn(D2WIN, -10184); + aItemInitMode = Addr(D2CLIENT, D2CLIENT_ITEM_INIT_MODE); + aAllocUnitGfx = Addr(D2CLIENT, D2CLIENT_ALLOC_UNIT_GFX); + aLinkItemStats = Addr(D2CLIENT, D2CLIENT_LINK_ITEM_STATS); + gSelectedInvItem = (UnitAny**)Addr(D2CLIENT, 0x11BC38); + gHoverGrid = (BOOL*)Addr(D2CLIENT, 0x11BC28); + gHoverBody = (BOOL*)Addr(D2CLIENT, 0x11BC2C); + gAboveX = (int*)Addr(D2CLIENT, 0xE0EA8); + gAboveY = (int*)Addr(D2CLIENT, 0xE0EAC); + gBelowX = (int*)Addr(D2CLIENT, 0xE0EB0); + gBelowY = (int*)Addr(D2CLIENT, 0xE0EB4); + gShopCursorType = (DWORD*)Addr(D2CLIENT, 0x11BC34); + gExpCharFlag = (DWORD*)Addr(D2CLIENT, 0x119854); + gChatMsg = (wchar_t*)Addr(D2CLIENT, 0x11EC80); + gChatMsgLen = (DWORD*)Addr(D2CLIENT, 0x11C028); + gHoverPopupText = (wchar_t*)Addr(D2WIN, 0xC9E58); + gHoverPopup = (DWORD*)Addr(D2WIN, 0xCA658); + nextId = GetTickCount() & 0xFFFFF; + ready = true; + return true; +} + +bool Ready() { + return ready; +} + +bool Tables(ItemLinkCodec::Tables* t) { + memset(t, 0, sizeof *t); + sgptDataTable* g = *p_D2COMMON_sgptDataTable; + if (!ready || !g) + return false; + D2ItemDataTbl* items = D2COMMON_10535_DATATBLS_GetItemDataTables(); + D2MagicAffixDataTbl* magic = D2COMMON_10492_DATATBLS_GetMagicAffixDataTables(); + if (!items || !magic || !items->pItemsTxt || !magic->pMagicAffixTxt || !g->pItemStatCostTxt || !g->pItemsTypeTxt) + return false; + // The rare-affix block {count, all, suffixes, prefixes} lives in the data tables between the item + // and magic-affix blocks; no export returns it, so find it by its shape (suffixes first, both + // pointers inside the one array of 0x48-byte records). + const RareAffixDataTbl* rare = NULL; + for (const BYTE* p = (const BYTE*)items; p + sizeof(RareAffixDataTbl) <= (const BYTE*)magic + 0x200 && !rare; p += 4) { + const RareAffixDataTbl* r = (const RareAffixDataTbl*)p; + if (r->nRareAffixTxtRecordCount < 16 || r->nRareAffixTxtRecordCount > 1024 || !r->pRareAffixTxt || + r->pRareSuffix != r->pRareAffixTxt || r->pRarePrefix <= r->pRareSuffix || + r->pRarePrefix >= r->pRareAffixTxt + r->nRareAffixTxtRecordCount) + continue; + rare = r; + } + if (!rare) + return false; + t->isc = g->pItemStatCostTxt; + t->iscCount = g->dwItemStatCostRecs; + t->iscStride = sizeof(ItemStatCostTxt); + t->items = items->pItemsTxt; + t->itemsCount = items->nItemsTxtRecordCount; + t->itemsStride = sizeof(ItemsTxt); + t->weaponsCount = items->nWeaponsTxtRecordCount; + t->armorCount = items->nArmorTxtRecordCount; + t->itemTypes = g->pItemsTypeTxt; + t->itemTypesCount = g->dwItemsTypeRecs; + t->itemTypesStride = sizeof(D2ItemTypesTxt); + t->skillsCount = g->dwSkillsRecs; + t->magicSuffixCount = (unsigned)(magic->pMagicPrefix - magic->pMagicSuffix); + t->magicPrefixCount = (unsigned)(magic->pAutoMagic - magic->pMagicPrefix); + t->autoMagicCount = (unsigned)(magic->nMagicAffixTxtRecordCount - (magic->pAutoMagic - magic->pMagicAffixTxt)); + t->rareSuffixCount = (unsigned)(rare->pRarePrefix - rare->pRareSuffix); + t->rarePrefixCount = (unsigned)(rare->nRareAffixTxtRecordCount - (rare->pRarePrefix - rare->pRareAffixTxt)); + t->uniqueCount = g->dwUniqItemsRecs; + t->setItemCount = g->dwSetItemsRecs; + int* runes = D2COMMON_GetRunesTxtRecords(); + t->runesCount = runes ? (unsigned)*runes : 0; + // 3-bit fields: LowQualityItems has 4 rows, QualityItems 8 (PD2 S13 tables). + t->lowQualityCount = 4; + t->qualityItemsCount = 8; + t->classCount = 7; + t->monStatsCount = g->dwMonStatsRecs; + t->monTypeCount = g->dwMonTypeRecs; + t->statesCount = g->dwStatesTxtRecs; + return t->iscCount > 0 && t->itemsCount > 0 && t->magicPrefixCount < 0x800 && t->magicSuffixCount < 0x800 && + t->rareSuffixCount > 0 && t->rareSuffixCount < 256 && t->rarePrefixCount < 256; +} + +size_t SerializeItem(UnitAny* item, uint8_t* out, size_t cap) { + if (!ready || !item || item->dwType != UNIT_ITEM || !item->pItemData) + return 0; + return pSaveItem(item, out, (DWORD)cap, TRUE, TRUE, FALSE); +} + +UnitAny* CreateItem(const ItemLinkCodec::Tables& t, const ItemLinkCodec::Link& link) { + if (!ready || link.count < 1) + return NULL; + BYTE rec[ItemLinkCodec::kMaxPayload + 2]; + int socketed = 0; + DWORD n = (DWORD)ItemLinkCodec::EncodeRecord(t, link.items[0], true, rec, sizeof rec); + if (!n) + return NULL; + UnitAny* parent = BuildOne(link.items[0], rec, n, &socketed); + if (!parent) + return NULL; + if (socketed != link.count - 1) { + FreeItem(parent); + return NULL; + } + for (int i = 1; i < link.count; ++i) { + int s = 0; + n = (DWORD)ItemLinkCodec::EncodeRecord(t, link.items[i], true, rec, sizeof rec); + UnitAny* child = n ? BuildOne(link.items[i], rec, n, &s) : NULL; + if (!child) { + FreeItem(parent); + return NULL; + } + if (!parent->pInventory || !pPlaceInSocket(parent->pInventory, child, 1)) { + FreeItem(child); + FreeItem(parent); + return NULL; + } + LinkItemStats(parent, child); + } + return parent; +} + +void FreeItem(UnitAny* item) { + if (!item) + return; + // Detach and free the socket fillers first: the engine's own child cleanup looks children up in + // the client unit table by id, which transient children are never in. + if (item->pInventory) { + for (int guard = 0; guard < 16; ++guard) { + UnitAny* c = pInvFirst(item->pInventory); + if (!c) + break; + if (!(c->pItemData && (c->pItemData->dwFlags & 0x100))) + pUnmerge(item, c); + pInvRemove(item->pInventory, c); + pClearHover(c); + pFreeClientUnit(c); + } + } + pClearHover(item); + pFreeClientUnit(item); +} + +bool ItemName(UnitAny* item, wchar_t* out, size_t cap) { + if (!ready || !item || cap < 2) + return false; + // Size 191 is the NPC-buy popup's: BH's loot-filter name hook leaves that name as the game made it. + wchar_t buf[192]; + buf[0] = 0; + if (!pGetItemName(item, buf, 191)) + return false; + buf[191] = 0; + // Multi-line names (unique/set/runeword + base type): the game draws the last line on top. + wchar_t* line = buf; + for (wchar_t* p = buf; *p; ++p) + if (*p == L'\n' && p[1]) + line = p + 1; + size_t n = 0; + for (wchar_t* p = line; *p && *p != L'\n' && n + 1 < cap;) { + if (p[0] == 0xFF && p[1] == L'c' && p[2]) { + p += 3; + continue; + } + out[n++] = *p++; + } + while (n && out[n - 1] == L' ') + --n; + out[n] = 0; + return n > 0; +} + +int NameColor(const ItemLinkCodec::Item& it) { + if (it.flags & 0x04000000) // runeword + return 4; + switch (it.quality) { + case 4: return 3; // magic: blue + case 5: return 2; // set: green + case 6: return 9; // rare: yellow + case 7: return 4; // unique: gold + case 8: return 8; // crafted: orange + default: return (it.flags & (0x00400000 | 0x00000800)) ? 5 : 0; // ethereal/socketed grey, else white + } +} + +void DrawTooltip(UnitAny* item, int x, int yTop, int yBottom) { + if (!ready || !item || !item->pItemData) + return; + UnitAny* me = D2CLIENT_GetPlayerUnit(); + if (!me || !me->pInventory) + return; + if (gHoverPopupText[0]) + return; // the game drew its own popup this frame (a real hover wins) + if (pCursorItem(me->pInventory)) + return; // like the game: no item tooltips while holding an item + UnitAny* view = Item::GetViewUnit(); + Inventory* checked = (view && view->pInventory) ? view->pInventory : me->pInventory; + + UnitAny* sSel = *gSelectedInvItem; + BOOL sGrid = *gHoverGrid, sBody = *gHoverBody; + int sAX = *gAboveX, sAY = *gAboveY, sBX = *gBelowX, sBY = *gBelowY; + DWORD sShop = *gShopCursorType; + Inventory* sOwn = item->pItemData->pOwnerInventory; + + *gSelectedInvItem = item; + *gHoverGrid = FALSE; // grid flag off: no vendor price path + *gHoverBody = TRUE; + *gAboveX = x; + *gAboveY = yTop; + *gBelowX = x; + *gBelowY = yBottom; + *gShopCursorType = 0; + item->pItemData->pOwnerInventory = checked; // the builder's "is this the hovered inventory's item" check + + DWORD oldFont = pSetFont(1); // the inventory panel's font for the popup + pDrawHoverPopup(me, FALSE); + if (gHoverPopupText[0]) { + DropEquippableQuantity(item, yTop, yBottom); + pRenderHoverPopup(); + } + pSetHoverPopup(NULL, 0, 0, 0, 0); + pSetFont(oldFont); + + item->pItemData->pOwnerInventory = sOwn; + *gShopCursorType = sShop; + *gAboveX = sAX; + *gAboveY = sAY; + *gBelowX = sBX; + *gBelowY = sBY; + *gHoverGrid = sGrid; + *gHoverBody = sBody; + *gSelectedInvItem = sSel; +} + +int TextWidth(const wchar_t* s, int n) { + // D2Win's TextNWidth skips colour codes, except one that ends the measured span: that one is + // measured as three glyphs. Colour codes have no width, so leave trailing ones out. + while (n >= 3 && s[n - 3] == 0xFF && s[n - 2] == L'c') + n -= 3; + if (!ready || n <= 0) + return 0; + return (int)pTextNWidth(s, n); +} + +bool AppendChatInput(const wchar_t* text) { + if (!ready) + return false; + if (!D2CLIENT_GetUIState(UI_CHAT_CONSOLE)) + D2CLIENT_SetUIVar(UI_CHAT_CONSOLE, 0, 0); // opening clears the box, so open first + DWORD len = *gChatMsgLen; + if (len > 255) + return false; + bool space = len > 0 && gChatMsg[len - 1] != L' '; + size_t add = wcslen(text) + (space ? 1 : 0); + if (len + add > 255) + return false; + if (space) + gChatMsg[len++] = L' '; + wmemcpy(gChatMsg + len, text, wcslen(text)); + len += (DWORD)wcslen(text); + gChatMsg[len] = 0; + *gChatMsgLen = len; + return true; +} + +} // namespace ItemLinkGame diff --git a/BH/Modules/ItemLinks/ItemLinkGame.h b/BH/Modules/ItemLinks/ItemLinkGame.h new file mode 100644 index 00000000..779b8f83 --- /dev/null +++ b/BH/Modules/ItemLinks/ItemLinkGame.h @@ -0,0 +1,50 @@ +#pragma once +// Chat item links: everything that touches the game (Diablo II 1.13c client only). +// - the live data tables the codec validates against, +// - item -> save records (sender), validated canonical records -> a transient, view-only client +// item unit and back to nothing (receiver; never hashed, never in a room or inventory), +// - the item's own name, and the game's own hover tooltip for a transient unit. +#include +#include + +#include "ItemLinkCodec.h" + +struct UnitAny; + +namespace ItemLinkGame { + +// Resolve addresses and check the code bytes at every call/patch site. False = not 1.13c or the +// code isn't what we expect (another mod patched it): the feature stays off. *why names the site. +bool Init(const char** why); +bool Ready(); + +// Live tables (re-read every call; cheap). False if the game hasn't loaded them. +bool Tables(ItemLinkCodec::Tables* t); + +// The item's save records ("JM" + record for the item and each socketed child) via the engine's +// own serializer. Returns the byte count, 0 on failure. +size_t SerializeItem(UnitAny* item, uint8_t* out, size_t cap); + +// Build a transient item (+ socket fillers) from a validated, canonical link. The engine decodes +// the canonical records; any disagreement with the codec (failure, different length) frees it and +// returns null. +UnitAny* CreateItem(const ItemLinkCodec::Tables& t, const ItemLinkCodec::Link& link); +void FreeItem(UnitAny* item); + +// The game's name for the item (loot-filter renames bypassed), last line, colour codes removed. +bool ItemName(UnitAny* item, wchar_t* out, size_t cap); +// D2 text colour of an item name by quality (unique gold, set green, rare yellow, ...). +int NameColor(const ItemLinkCodec::Item& it); + +// The game's own item tooltip for `item`, anchored like an inventory hover on the box +// (x centre, yTop..yBottom). Call from the in-game draw (BH OnDraw). Skipped while the game +// staged a popup of its own this frame or an item is on the cursor. +void DrawTooltip(UnitAny* item, int x, int yTop, int yBottom); + +// Width in pixels of the first n characters of s in the current font (colour codes are zero-width). +int TextWidth(const wchar_t* s, int n); + +// Chat input box: append text (opens the box first). False if it wouldn't fit (255 characters). +bool AppendChatInput(const wchar_t* text); + +} // namespace ItemLinkGame diff --git a/BH/Modules/ItemLinks/ItemLinks.cpp b/BH/Modules/ItemLinks/ItemLinks.cpp new file mode 100644 index 00000000..7d1d5f81 --- /dev/null +++ b/BH/Modules/ItemLinks/ItemLinks.cpp @@ -0,0 +1,674 @@ +#include "ItemLinks.h" + +#include +#include +#include + +#include "../../BH.h" +#include "../../D2Ptrs.h" +#include "../../Constants.h" +#include "ChatLinkApi.h" +#include "ItemLinkCodec.h" +#include "ItemLinkGame.h" + +using namespace ItemLinkCodec; + +// --------------------------------------------------------------------------------------------------- +// How links appear in the game's own chat (1.13c D2Client): +// * Receive: every chat/system line the client stores goes through PrintGameString (0x7D850), +// which word-wraps and stores it; its entry is hooked (PrintGameStringHook, the one place a +// line is rewritten): every "#i" word that validates is replaced, together with an +// optional "[typed name] " just before it, by a zero-width marker and "[Item Name]" in the +// item's quality colour. The marker is six colour codes, "ÿc0ÿc0ÿcDÿcDÿcDÿcQ": a fixed lead-in, +// three digits of the link handle and the name colour; colour codes have no width, so the +// line wraps and measures exactly as it reads. A token that doesn't validate stays plain text. +// * Draw: the DrawText calls that draw chat lines (overlay 0x7B8FE/0x7B872, message log +// 0x67B69/0x67BD4) go through ChatDrawTextHook, which records the screen box of every link +// name drawn this frame (a name cut by the wrap continues, coloured, on the next row). +// * Click: a left click inside a recorded box opens the tooltip (the engine's own hover popup +// for a transient copy of the item); Esc, right-click or any other click closes it. +// --------------------------------------------------------------------------------------------------- + +namespace { + +const int kMaxEntries = 256; // live link handles +const int kVanillaRefs = 64; // the game chat keeps references to its most recent links +const int kMaxHandle = 999; // three digits in the marker +const int kMaxHits = 64; +const int kMaxNameChars = 64; +const int kLineBufChars = 1024; +const int kBoxAscent = 14, kBoxDescent = 2; // chat rows: text box [y-14, y+2] around the baseline + +struct Entry { + int refs; + uint8_t payload[kMaxPayload]; + size_t len; + wchar_t name[kMaxNameChars]; + int color; +}; +std::map entries; +int lastHandle = 0; +int vanillaRing[kVanillaRefs]; +int vanillaPos = 0; + +struct Hit { + int x0, x1, y0, y1, handle; +}; +Hit hitsCur[kMaxHits], hitsLast[kMaxHits]; +int nHitsCur = 0, nHitsLast = 0; +int pendingHandle = 0, pendingColor = 0; // a link name wrapped onto the next drawn row + +struct Popup { + bool open; + int handle; + UnitAny* item; + int x, yTop, yBottom; +} popup = {}; + +bool installed = false; +bool swallowUp = false; +std::vector patches; +const char* offReason = NULL; // why the feature couldn't start (told once, in game) +bool offTold = false; + +bool Enabled() { + return installed && App.itemLinks.enabled.value && ItemLinkGame::Ready(); +} + +// ---- registry -------------------------------------------------------------------------------------- + +int FindPayload(const uint8_t* p, size_t n) { + for (auto& e : entries) + if (e.second.len == n && memcmp(e.second.payload, p, n) == 0) + return e.first; + return 0; +} + +int NewHandle() { + for (int i = 0; i < kMaxHandle; ++i) { + lastHandle = lastHandle % kMaxHandle + 1; + if (!entries.count(lastHandle)) + return lastHandle; + } + return 0; +} + +void AddRef(int h) { + auto it = entries.find(h); + if (it != entries.end()) + ++it->second.refs; +} + +// Validate a token (the base64 text after "#i") and register it. Returns a handle holding one +// reference, or 0 (invalid, the engine disagreed, or the registry is full). +int Register(const wchar_t* b64, size_t len) { + if (len < kMinTokenChars || len > kMaxTokenChars) + return 0; + char text[kMaxTokenChars]; + for (size_t i = 0; i < len; ++i) { + if (b64[i] > 0x7F) + return 0; + text[i] = (char)b64[i]; + } + ItemLinkCodec::Tables t; + if (!ItemLinkGame::Tables(&t)) + return 0; + static Link link; // large; the game thread is the only caller + if (ParseToken(t, text, len, &link) != OK) + return 0; + Canonicalize(&link); + uint8_t payload[kMaxPayload]; + size_t n = EncodePayload(t, link, payload, sizeof payload); + if (!n) + return 0; + int h = FindPayload(payload, n); + if (h) { + AddRef(h); + return h; + } + if ((int)entries.size() >= kMaxEntries || !(h = NewHandle())) + return 0; + UnitAny* item = ItemLinkGame::CreateItem(t, link); + if (!item) + return 0; + Entry e; + e.refs = 1; + memcpy(e.payload, payload, n); + e.len = n; + bool named = ItemLinkGame::ItemName(item, e.name, kMaxNameChars); + ItemLinkGame::FreeItem(item); + if (!named) + return 0; + e.color = ItemLinkGame::NameColor(link.items[0]); + entries[h] = e; + return h; +} + +void ClosePopup() { + if (popup.item) + ItemLinkGame::FreeItem(popup.item); + popup = Popup(); +} + +void Unref(int h) { + auto it = entries.find(h); + if (it == entries.end()) + return; + if (--it->second.refs <= 0) { + if (popup.open && popup.handle == h) + ClosePopup(); + entries.erase(it); + } +} + +void HoldForGameChat(int h) { + int old = vanillaRing[vanillaPos]; + vanillaRing[vanillaPos] = h; + vanillaPos = (vanillaPos + 1) % kVanillaRefs; + if (old) + Unref(old); +} + +// ---- markers --------------------------------------------------------------------------------------- + +const wchar_t kC = 0xFF; + +wchar_t ColorChar(int c) { + return (wchar_t)(L'0' + (c < 0 ? 0 : c > 12 ? 0 : c)); +} + +// Append the marker + "[name]" + the colour to resume with. +void AppendLink(std::wstring& out, int h, const Entry& e, wchar_t resume) { + const wchar_t lead[] = { kC, L'c', L'0', kC, L'c', L'0', 0 }; + out += lead; + int digits[3] = { h / 100 % 10, h / 10 % 10, h % 10 }; + for (int d : digits) { + out += kC; + out += L'c'; + out += (wchar_t)(L'0' + d); + } + out += kC; + out += L'c'; + out += ColorChar(e.color); + out += L'['; + out += e.name; + out += L']'; + out += kC; + out += L'c'; + out += resume; +} + +// A marker at s[i]? Returns the handle, *nameAt = index of '[', *color = the name colour. +int MarkerAt(const wchar_t* s, size_t len, size_t i, size_t* nameAt, int* color) { + if (i + 19 > len) + return 0; + for (int k = 0; k < 6; ++k) + if (s[i + 3 * k] != kC || s[i + 3 * k + 1] != L'c') + return 0; + if (s[i + 2] != L'0' || s[i + 5] != L'0' || s[i + 18] != L'[') + return 0; + int h = 0; + for (int k = 2; k < 5; ++k) { + wchar_t d = s[i + 3 * k + 2]; + if (d < L'0' || d > L'9') + return 0; + h = h * 10 + (d - L'0'); + } + auto it = entries.find(h); + if (it == entries.end()) + return 0; + // The name must be the registered one (a hand-typed marker can't relabel a link). + size_t nl = wcslen(it->second.name); + if (i + 19 + nl < len && s[i + 19 + nl] != L']') + return 0; + if (wcsncmp(s + i + 19, it->second.name, (std::min)(nl, len - (i + 19))) != 0) + return 0; + *nameAt = i + 18; + *color = it->second.color; + return h; +} + +// The colour in effect just before s[pos] (the line's own colour unless a code changed it). +wchar_t ColorBefore(const wchar_t* s, size_t pos, int lineColor) { + wchar_t c = ColorChar(lineColor); + for (size_t i = 0; i + 2 < pos; ++i) + if (s[i] == kC && s[i + 1] == L'c') + c = s[i + 2]; + return c; +} + +// Where a "[typed name] " right before the token at `tok` starts, else `tok`. +size_t TypedNameStart(const wchar_t* s, size_t tok) { + if (tok < 3 || s[tok - 1] != L' ' || s[tok - 2] != L']') + return tok; + for (size_t i = tok - 2; i-- > 0 && tok - i <= 2 + kMaxNameChars;) { + if (s[i] == L']') + return tok; + if (s[i] == L'[') + return i; + } + return tok; +} + +// Rewrite a whole line for the game chat; the game chat holds a reference on each link (its most +// recent kVanillaRefs links stay clickable). Returns false if the line has no valid link. +bool RewriteLine(const wchar_t* in, int lineColor, std::wstring& out) { + size_t len = wcslen(in); + size_t a = 0, b = 0, pos = 0, copied = 0; + bool any = false; + out.clear(); + while (FindToken(in, len, pos, &a, &b)) { + int h = Register(in + a + 2, b - a - 2); + pos = b; + if (!h) + continue; + size_t start = TypedNameStart(in, a); + out.append(in + copied, start - copied); + AppendLink(out, h, entries[h], ColorBefore(in, start, lineColor)); + copied = b; + any = true; + HoldForGameChat(h); + } + if (!any) + return false; + out.append(in + copied); + return true; +} + +// ---- hooks ----------------------------------------------------------------------------------------- + +typedef void(__stdcall* PrintGameString_t)(wchar_t* msg, int color); +PrintGameString_t origPrintGameString; // trampoline: the relocated first instructions + jmp back +ItemLinks::ChatLineObserver lineObserver = NULL; // ChatLinkApi SetLineObserver +bool printHooked = false; + +// Every chat/system line the client stores goes through PrintGameString (the game's chat +// formatter, PD2's own chat code and BH's messages all call it), so its entry is hooked. +void __stdcall PrintGameStringHook(wchar_t* msg, int color) { + if (msg && Enabled()) { + std::wstring out; + if (RewriteLine(msg, color, out) && out.size() < kLineBufChars) { + static wchar_t buf[kLineBufChars]; + wcsncpy_s(buf, out.c_str(), _TRUNCATE); + if (lineObserver) + lineObserver(buf, color); + origPrintGameString(buf, color); + return; + } + } + if (msg && lineObserver) + lineObserver(msg, color); + origPrintGameString(msg, color); +} + +// PrintGameString 0x7D850 starts `sub esp,0x14; push ebx; push ebp; push esi; push edi; xor ebp,ebp`: +// its first 5 bytes are whole, position-independent instructions. +const DWORD PRINT_GAME_STRING = 0x7D850; +const BYTE kPrintPrologue[] = { 0x83, 0xEC, 0x14, 0x53, 0x55, 0x56, 0x57, 0x33, 0xED }; +BYTE* printTrampoline = NULL; + +bool HookPrintGameString() { + if (printHooked) + return true; + BYTE* fn = (BYTE*)Patch::GetDllOffset(D2CLIENT, PRINT_GAME_STRING); + if (!fn || memcmp(fn, kPrintPrologue, sizeof kPrintPrologue) != 0) + return false; + if (!printTrampoline) { + printTrampoline = (BYTE*)VirtualAlloc(NULL, 16, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); + if (!printTrampoline) + return false; + memcpy(printTrampoline, fn, 5); + printTrampoline[5] = 0xE9; + *(int*)(printTrampoline + 6) = (int)(fn + 5) - (int)(printTrampoline + 10); + origPrintGameString = (PrintGameString_t)printTrampoline; + } + BYTE jmp[5] = { 0xE9 }; + *(int*)(jmp + 1) = (int)PrintGameStringHook - (int)(fn + 5); + printHooked = Patch::WriteBytes((int)fn, 5, jmp); + return printHooked; +} + +void UnhookPrintGameString() { + BYTE* fn = (BYTE*)Patch::GetDllOffset(D2CLIENT, PRINT_GAME_STRING); + if (fn && fn[0] == 0xE9 && printTrampoline) + Patch::WriteBytes((int)fn, 5, (BYTE*)kPrintPrologue); + printHooked = false; +} + +void AddHit(int x0, int x1, int y, int h) { + if (nHitsCur < kMaxHits && x1 > x0) + hitsCur[nHitsCur++] = { x0, x1, y - kBoxAscent, y + kBoxDescent, h }; +} + +void __fastcall ChatDrawTextHook(const wchar_t* s, int x, int y, DWORD color, DWORD centered) { + if (!s || !Enabled() || centered) { + D2WIN_DrawText(s, x, y, color, centered); + return; + } + size_t len = wcslen(s); + const wchar_t* draw = s; + static wchar_t cont[kLineBufChars]; + size_t from = 0; + if (pendingHandle) { // the rest of a wrapped link name starts this row + size_t close = 0; + while (close < len && s[close] != L']') + ++close; + if (len + 4 < kLineBufChars) { + cont[0] = kC; + cont[1] = L'c'; + cont[2] = ColorChar(pendingColor); + wmemcpy(cont + 3, s, len + 1); + draw = cont; + } + AddHit(x, x + ItemLinkGame::TextWidth(s, (int)(close < len ? close + 1 : len)), y, pendingHandle); + from = close < len ? close + 1 : len; + if (close < len) + pendingHandle = 0; + } + for (size_t i = from; i < len; ++i) { + size_t nameAt = 0; + int col = 0; + int h = MarkerAt(s, len, i, &nameAt, &col); + if (!h) + continue; + size_t close = nameAt; + while (close < len && s[close] != L']') + ++close; + int x0 = x + ItemLinkGame::TextWidth(s, (int)nameAt); + int x1 = x + ItemLinkGame::TextWidth(s, (int)(close < len ? close + 1 : len)); + AddHit(x0, x1, y, h); + if (close >= len) { + pendingHandle = h; + pendingColor = col; + } + i = close; + } + D2WIN_DrawText(draw, x, y, color, centered); +} + +struct SiteHook { + int offset; + DWORD expectTarget; // RVA the original call goes to + int fn; +}; + +bool CallTargets(DWORD site, DWORD target) { + const BYTE* p = (const BYTE*)site; + return p[0] == 0xE8 && site + 5 + *(const int*)(p + 1) == target; +} + +bool InstallHooks() { + if (installed) + return true; + const SiteHook sites[] = { + // DrawText (import thunk 0xD37E) calls that draw chat rows: overlay, overlay re-wrapped, log + { 0x7B8FE, 0xD37E, (int)ChatDrawTextHook }, { 0x7B872, 0xD37E, (int)ChatDrawTextHook }, + { 0x67B69, 0xD37E, (int)ChatDrawTextHook }, { 0x67BD4, 0xD37E, (int)ChatDrawTextHook }, + }; + for (const SiteHook& s : sites) + if (!CallTargets(Patch::GetDllOffset(D2CLIENT, s.offset), Patch::GetDllOffset(D2CLIENT, s.expectTarget))) + return false; // someone else owns a site: stay off rather than chain blindly + if (!HookPrintGameString()) + return false; + if (patches.empty()) + for (const SiteHook& s : sites) + patches.push_back(new Patch(Call, D2CLIENT, { s.offset, 0 }, s.fn, 5)); // BH keeps every Patch for life + for (Patch* p : patches) + p->Install(); + installed = true; + return true; +} + +void RemoveHooks() { + for (Patch* p : patches) + p->Remove(); + if (!lineObserver) + UnhookPrintGameString(); + installed = false; +} + +// ---- sender ---------------------------------------------------------------------------------------- + +void Say(const wchar_t* msg) { + std::wstring s = L"\xFF" L"c4Item links:\xFF" L"c0 "; + s += msg; + static wchar_t buf[256]; + wcsncpy_s(buf, s.c_str(), _TRUNCATE); + D2CLIENT_PrintGameString(buf, 0); +} + +bool LinkHoveredItem() { + UnitAny* item = *p_D2CLIENT_SelectedInvItem; + if (!item || item->dwType != UNIT_ITEM || !item->pItemData) + return false; + if (!(item->pItemData->dwFlags & ITEM_IDENTIFIED)) { + Say(L"identify the item first."); + return true; + } + ItemLinkCodec::Tables t; + uint8_t save[0x400]; + size_t n = ItemLinkGame::Tables(&t) ? ItemLinkGame::SerializeItem(item, save, sizeof save) : 0; + static Link link; + Result r = n ? ParseSaveRecords(t, save, n, &link) : ERR_TABLES; + char token[kMaxTokenChars + 1]; + size_t tn = 0; + if (r == OK) { + Canonicalize(&link); + tn = EncodeToken(t, link, token, sizeof token); + } + wchar_t name[kMaxNameChars]; + if (!tn || !ItemLinkGame::ItemName(item, name, kMaxNameChars)) { + std::string why = r == OK ? "too large" : ResultName(r); + Say((L"can't link this item (" + std::wstring(why.begin(), why.end()) + L").").c_str()); + return true; + } + std::wstring text = L"["; + text += name; + text += L"] #i"; + text.append(token, token + tn); + if (!ItemLinkGame::AppendChatInput(text.c_str())) + Say(L"the link doesn't fit in the chat box (255 characters)."); + return true; +} + +bool KeyDown(int vk) { + return (GetKeyState(vk) & 0x8000) != 0; +} + +bool OpenLink(int h, int x, int yTop, int yBottom) { + auto it = entries.find(h); + if (it == entries.end()) + return false; + ClosePopup(); + ItemLinkCodec::Tables t; + static Link link; + if (!ItemLinkGame::Tables(&t) || ParsePayload(t, it->second.payload, it->second.len, &link) != OK) + return false; + UnitAny* item = ItemLinkGame::CreateItem(t, link); + if (!item) + return false; + popup.open = true; + popup.handle = h; + popup.item = item; + popup.x = x; + popup.yTop = yTop; + popup.yBottom = yBottom; + AddRef(h); // the popup keeps the entry alive while open + return true; +} + +void ClosePopupAndUnref() { + if (!popup.open) + return; + int h = popup.handle; + ClosePopup(); + Unref(h); +} + +} // namespace + +// ---- ChatLinkApi ----------------------------------------------------------------------------------- + +namespace ItemLinks { + +void FormatMessage(const wchar_t* msg, std::vector& out) { + out.clear(); + if (!msg) + return; + size_t len = wcslen(msg); + std::wstring plain; + auto flush = [&]() { + if (!plain.empty()) { + out.push_back({ plain, -1, 0 }); + plain.clear(); + } + }; + for (size_t i = 0; i < len;) { + size_t nameAt = 0, a = 0, b = 0; + int col = 0; + int h = Enabled() ? MarkerAt(msg, len, i, &nameAt, &col) : 0; + if (h) { + size_t close = nameAt; + while (close < len && msg[close] != L']') + ++close; + flush(); + AddRef(h); + out.push_back({ L"[" + std::wstring(entries[h].name) + L"]", col, h }); + i = close < len ? close + 1 : len; + if (i + 2 < len && msg[i] == kC && msg[i + 1] == L'c') + i += 3; // the colour the game-chat rewrite resumes with + continue; + } + if (Enabled() && msg[i] == L'#' && FindToken(msg, len, i, &a, &b) && a == i) { + int t = Register(msg + a + 2, b - a - 2); + if (t) { + size_t start = TypedNameStart(msg, a); + if (start < a && plain.size() >= a - start) + plain.resize(plain.size() - (a - start)); + flush(); + out.push_back({ L"[" + std::wstring(entries[t].name) + L"]", entries[t].color, t }); + i = b; + continue; + } + } + plain += msg[i++]; + } + flush(); +} + +void Release(int link) { + if (link > 0) + Unref(link); +} + +bool OnClick(int link, int x, int yTop, int yBottom) { + return Enabled() && OpenLink(link, x, yTop, yBottom); +} + +bool SetLineObserver(ChatLineObserver cb) { + lineObserver = cb; + if (cb) + return HookPrintGameString(); + if (!installed) + UnhookPrintGameString(); + return true; +} + +} // namespace ItemLinks + +// ---- module ---------------------------------------------------------------------------------------- + +void ChatItemLinks::OnLoad() { + LoadConfig(); + if (!ItemLinkGame::Init(&offReason)) + return; // not 1.13c, or unexpected code at a site we call: the feature stays off + if (!InstallHooks()) + offReason = "a chat call site is already patched by another module"; +} + +void ChatItemLinks::OnUnload() { + ClosePopupAndUnref(); + RemoveHooks(); +} + +void ChatItemLinks::LoadConfig() {} + +void ChatItemLinks::OnGameJoin() { + nHitsCur = nHitsLast = 0; + pendingHandle = 0; +} + +void ChatItemLinks::OnGameExit() { + ClosePopupAndUnref(); + for (int& h : vanillaRing) { + if (h) + Unref(h); + h = 0; + } + nHitsCur = nHitsLast = 0; + pendingHandle = 0; +} + +void ChatItemLinks::OnDraw() { + if (offReason && *offReason && !offTold && App.itemLinks.enabled.value) { + offTold = true; + std::string r(offReason); + Say((L"off (" + std::wstring(r.begin(), r.end()) + L").").c_str()); + } + // The chat rows of this frame were drawn before BH's draw hook: publish their link boxes. + memcpy(hitsLast, hitsCur, sizeof(Hit) * nHitsCur); + nHitsLast = nHitsCur; + nHitsCur = 0; + pendingHandle = 0; + if (popup.open) { + if (!Enabled()) + ClosePopupAndUnref(); + else + ItemLinkGame::DrawTooltip(popup.item, popup.x, popup.yTop, popup.yBottom); + } +} + +void ChatItemLinks::OnLeftClick(bool up, int x, int y, bool* block) { + if (up) { + if (swallowUp) { + swallowUp = false; + *block = true; + } + return; + } + if (!Enabled() || !D2CLIENT_GetPlayerUnit()) + return; + if (KeyDown(VK_CONTROL) && KeyDown(VK_SHIFT) && LinkHoveredItem()) { + *block = true; + swallowUp = true; + return; + } + for (int i = 0; i < nHitsLast; ++i) { + const Hit& h = hitsLast[i]; + if (x >= h.x0 && x < h.x1 && y >= h.y0 && y <= h.y1) { + bool same = popup.open && popup.handle == h.handle; + ClosePopupAndUnref(); + if (!same) + OpenLink(h.handle, (h.x0 + h.x1) / 2, h.y0, h.y1); + *block = true; + swallowUp = true; + return; + } + } + if (*block) + return; // another chat UI consumed the click (e.g. opened a link through OnClick) + ClosePopupAndUnref(); // a click anywhere else closes it and goes on to the game +} + +void ChatItemLinks::OnRightClick(bool up, int x, int y, bool* block) { + if (!up) + ClosePopupAndUnref(); +} + +void ChatItemLinks::OnKey(bool up, BYTE key, LPARAM lParam, bool* block) { + if (key == VK_ESCAPE && popup.open) { + if (!up) + ClosePopupAndUnref(); + *block = true; + } +} diff --git a/BH/Modules/ItemLinks/ItemLinks.h b/BH/Modules/ItemLinks/ItemLinks.h new file mode 100644 index 00000000..f5118be4 --- /dev/null +++ b/BH/Modules/ItemLinks/ItemLinks.h @@ -0,0 +1,24 @@ +#pragma once +// Chat item links. +// Send: Ctrl+Shift+left-click an item in the inventory/stash/cube/equipment -> "[Item Name] #i" +// is appended to the chat input box. +// Receive: chat lines carrying a valid token show "[Item Name]" in the item's quality colour with +// the token hidden; left-click the name -> the game's own item tooltip (click elsewhere, +// right-click or Esc closes it). +// Token format and validation: ItemLinkCodec.h. Other chat UIs: ChatLinkApi.h. +#include "../Module.h" + +class ChatItemLinks : public Module { +public: + ChatItemLinks() : Module("Item Links") {} + + void OnLoad(); + void OnUnload(); + void LoadConfig(); + void OnGameJoin(); + void OnGameExit(); + void OnDraw(); + void OnLeftClick(bool up, int x, int y, bool* block); + void OnRightClick(bool up, int x, int y, bool* block); + void OnKey(bool up, BYTE key, LPARAM lParam, bool* block); +}; diff --git a/README.md b/README.md index 027f224b..32a13c5a 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,23 @@ You can change the command line arguments as you see fit. ``` Any debug or changed version will only work in *Single Player*. Do not enter multiplayer with a modified BH + +## Chat panel + +BH replaces the in-game chat overlay with a tabbed panel (`BH/Modules/Chat`). It opens with the chat box (Enter) and shows the +recent lines of the selected tab, like the vanilla overlay, while you are not typing. + +* Tabs: **All**, **Game** (what players in this game say, party members and your own lines included), **Whispers** + (in and out, one tab), **System** (server text, game and party events, item notifications, BH/PD2 messages) and + **Global** (placeholder until realm-wide chat exists). Tabs you are not looking at show an unread count. +* Scrollback: the last `history_lines` lines (shared by all tabs). Mouse wheel over the panel, PgUp/PgDn, Home (oldest), End or + the "Jump to latest" button. +* Lines containing your character name or a `mention_keywords` entry (whole word, any case) are highlighted, with an optional + sound (`mention_sound`, a Sounds.txt row). +* While typing: Up/Down recall your last 50 sent lines (the unsent text comes back at the end), Ctrl+Tab / Ctrl+Shift+Tab switch + tabs. Ctrl+R (`reply_hotkey`) starts a whisper to whoever whispered you last. +* Commands typed in the chat box: `.chat on|off` (off = vanilla chat), `.chat ts` (timestamps), `.chat tab `, + `.chat clear`, `.chat status`. + +`BH.json` section `chat`: `enabled`, `timestamps`, `mention_highlight`, `mention_sound`, `mention_keywords`, `history_lines`, +`visible_rows`, `width`, `fade_seconds`, `fade_rows`, `always_open`, `reply_hotkey`.