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