diff --git a/BH/BH.cpp b/BH/BH.cpp index 984d385b..1e754d3f 100644 --- a/BH/BH.cpp +++ b/BH/BH.cpp @@ -106,6 +106,7 @@ void BH::Initialize() new StashExport(); new MapNotify(); new ChatColor(); + new ChatItemLinks(); moduleManager->LoadModules(); diff --git a/BH/BH.h b/BH/BH.h index ecf3cb63..4b9a9d42 100644 --- a/BH/BH.h +++ b/BH/BH.h @@ -184,6 +184,11 @@ struct BHApp SettingsBool usingHDText = { false, false }; } d2gl; + struct + { + SettingsBool enabled = { true, true }; // chat item links (Modules/ItemLinks) + } itemLinks; + struct { pd2PlaySoundImpl_t pd2PlaySoundImpl = NULL; diff --git a/BH/BH.vcxproj b/BH/BH.vcxproj index 8ddb85f4..704a973f 100644 --- a/BH/BH.vcxproj +++ b/BH/BH.vcxproj @@ -192,6 +192,9 @@ powershell.exe -NoProfile -File "$(ProjectDir)..\..\scripts\CopyDataFolder.ps1"< + + + @@ -244,6 +247,10 @@ powershell.exe -NoProfile -File "$(ProjectDir)..\..\scripts\CopyDataFolder.ps1"< + + + + diff --git a/BH/BH.vcxproj.filters b/BH/BH.vcxproj.filters index aea5feb3..550da40b 100644 --- a/BH/BH.vcxproj.filters +++ b/BH/BH.vcxproj.filters @@ -69,6 +69,15 @@ Modules\ChatColor + + Modules\ItemLinks + + + Modules\ItemLinks + + + Modules\ItemLinks + Modules\Gamefilter @@ -174,6 +183,18 @@ Modules\ChatColor + + Modules\ItemLinks + + + Modules\ItemLinks + + + Modules\ItemLinks + + + Modules\ItemLinks + Modules\Gamefilter @@ -258,6 +279,9 @@ {f652bd02-2b0b-46a4-843c-20a0a9ea472d} + + {6d2a8f41-3b7e-4c19-9e5a-0f4b2c7d8e13} + {b2342ae5-3b17-4a7e-bd84-a5173ecce46d} diff --git a/BH/CMakeLists.txt b/BH/CMakeLists.txt index 4ea9a0de..a3070186 100644 --- a/BH/CMakeLists.txt +++ b/BH/CMakeLists.txt @@ -26,6 +26,9 @@ set(MODULE_SOURCES "Modules/Module.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..ac6592c3 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; @@ -391,6 +396,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); diff --git a/BH/Modules.h b/BH/Modules.h index efc62d03..7da9295a 100644 --- a/BH/Modules.h +++ b/BH/Modules.h @@ -11,4 +11,5 @@ #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" diff --git a/BH/Modules/ItemLinks/ChatLinkApi.h b/BH/Modules/ItemLinks/ChatLinkApi.h new file mode 100644 index 00000000..32c58a1b --- /dev/null +++ b/BH/Modules/ItemLinks/ChatLinkApi.h @@ -0,0 +1,34 @@ +#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); + +} // 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..84b0eba3 --- /dev/null +++ b/BH/Modules/ItemLinks/ItemLinks.cpp @@ -0,0 +1,652 @@ +#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 + +// 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); + origPrintGameString(buf, color); + return; + } + } + 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() { + 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); + return Patch::WriteBytes((int)fn, 5, jmp); +} + +void UnhookPrintGameString() { + BYTE* fn = (BYTE*)Patch::GetDllOffset(D2CLIENT, PRINT_GAME_STRING); + if (fn && fn[0] == 0xE9 && printTrampoline) + Patch::WriteBytes((int)fn, 5, (BYTE*)kPrintPrologue); +} + +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(); + 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); +} + +} // 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; + } + } + 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); +};