From 7c521eb8a16a1bf79e0fbba51f2de81c9dd12851 Mon Sep 17 00:00:00 2001 From: errolgr Date: Sun, 4 Oct 2026 21:12:37 -0400 Subject: [PATCH 1/2] Stats panel: Character | Mercenary tabs; merc page shows type, max life and a Skills section (skill/aura levels incl. +skills, attack skill damage) --- BH/D2Ptrs.h | 2 + BH/Drawing/Stats/StatsDisplay.cpp | 268 +++++++++++++++++++++++++++--- BH/Drawing/Stats/StatsDisplay.h | 6 + 3 files changed, 251 insertions(+), 25 deletions(-) diff --git a/BH/D2Ptrs.h b/BH/D2Ptrs.h index 2dd1b967..e954a0c6 100644 --- a/BH/D2Ptrs.h +++ b/BH/D2Ptrs.h @@ -423,6 +423,8 @@ FUNCPTR(D2COMMON, 10326_GetItemTxtFileNo, int __stdcall, (UnitAny* pItem), -1032 FUNCPTR(D2COMMON, 10364_Is1Or2Handed, int __stdcall, (UnitAny* pUnit, UnitAny* pItem), -10364); FUNCPTR(D2COMMON, 10567_GetMinPhysDamage, int __stdcall, (UnitAny* pUnit, int nSkillId, int nSkillLevel, BOOL a4), -10567); FUNCPTR(D2COMMON, 10297_GetMaxPhysDamage, int __stdcall, (UnitAny* pUnit, int nSkillId, int nSkillLevel, BOOL a4), -10297); +FUNCPTR(D2COMMON, 10121_GetMinElemDamage, int __stdcall, (UnitAny* pUnit, int nSkillId, int nSkillLevel, BOOL bSynergy), -10121); +FUNCPTR(D2COMMON, 11091_GetMaxElemDamage, int __stdcall, (UnitAny* pUnit, int nSkillId, int nSkillLevel, BOOL bSynergy), -11091); FUNCPTR(D2COMMON, 10592_GetWeaponAttackSpeed, int __stdcall, (UnitAny* pUnit, UnitAny* pItem), -10592); FUNCPTR(D2COMMON, 10711_ItemCheckIfThrowable, int __stdcall, (UnitAny* pItem), -10711) FUNCPTR(D2COMMON, 10757_CheckIfSocketable, int __stdcall, (UnitAny* pItem), -10757) diff --git a/BH/Drawing/Stats/StatsDisplay.cpp b/BH/Drawing/Stats/StatsDisplay.cpp index 444f25d7..8a5d9e32 100644 --- a/BH/Drawing/Stats/StatsDisplay.cpp +++ b/BH/Drawing/Stats/StatsDisplay.cpp @@ -201,6 +201,8 @@ StatsDisplay::StatsDisplay(std::string name) int width = 300; InitializeCriticalSection(&crit); + mercTabSelected = mercUiOpen = mercUiPage = false; + characterTab = mercTab = RECT{ 0, 0, 0, 0 }; SetY(yPos); SetXSize(width); @@ -254,6 +256,7 @@ void StatsDisplay::LoadConfig() int xPos = App.general.statsOnRight.value ? *p_D2CLIENT_ScreenSizeX - 10 - GetXSize() : 10; SetX(xPos); SetYSize(height); + characterYSize = mercYSize = GetYSize(); } void StatsDisplay::SetX(unsigned int newX) @@ -304,6 +307,33 @@ bool StatsDisplay::InRange(unsigned int x, x <= GetX() + GetXSize() && y <= GetY() + GetYSize(); } +static bool IsLivingMerc(UnitAny* pMerc) +{ + return pMerc && pMerc->dwMode != NPC_MODE_DEATH && pMerc->dwMode != NPC_MODE_DEAD; +} + +static bool InTab(const RECT& tab, unsigned int x, unsigned int y) +{ + return static_cast(x) >= tab.left && static_cast(x) < tab.right && + static_cast(y) >= tab.top && static_cast(y) < tab.bottom; +} + +// Monster skills without a skilldesc.txt row (hidden passives, the merc's own missile/pierce +// helpers) have no name: their index is 0 or past the table. +static bool HasSkillName(SkillsTxt* pSkillsTxt) +{ + return pSkillsTxt && pSkillsTxt->wSkillDesc && pSkillsTxt->wSkillDesc < (*p_D2COMMON_sgptDataTable)->dwSkillDescRecs; +} + +static std::string GetSkillName(SkillsTxt* pSkillsTxt) +{ + SkillDescTxt* pSkillDescTxt = &(*p_D2COMMON_sgptDataTable)->pSkillDescTxt[pSkillsTxt->wSkillDesc]; + char* szName = UnicodeToAnsi(GetTblEntryByIndex(pSkillDescTxt->wStrName, TBLOFFSET_STRING)); + std::string name = szName; + delete[] szName; + return name; +} + void StatsDisplay::Draw() { display->Lock(); @@ -325,11 +355,20 @@ void StatsDisplay::OnDraw() int xPos = App.general.statsOnRight.value ? *p_D2CLIENT_ScreenSizeX - 10 - GetXSize() : 10; SetX(xPos); - if (D2CLIENT_GetUIState(UI_MERC)) + // The Mercenary tab shows the merc's stats. Opening the merc inventory selects it (a tab + // clicked meanwhile counts until the inventory closes), then the user's tab is back. + UnitAny* pMerc = D2CLIENT_GetMercUnit(); + bool hasMerc = IsLivingMerc(pMerc); + bool mercUi = D2CLIENT_GetUIState(UI_MERC) != 0; + if (mercUi && !mercUiOpen) + mercUiPage = true; + mercUiOpen = mercUi; + if (hasMerc && (mercUi ? mercUiPage : mercTabSelected)) { - unit = D2CLIENT_GetMercUnit(); + unit = pMerc; isMerc = true; } + SetYSize(isMerc ? mercYSize : characterYSize); for (std::list::iterator it = Hooks.begin(); it != Hooks.end(); ++it) (*it)->OnDraw(); @@ -343,14 +382,57 @@ void StatsDisplay::OnDraw() Framehook::DrawRectStub(&pRect); + DrawTabs(column1, y + 8, isMerc, hasMerc); + y += 12; - Texthook::Draw(column1, - (y += 8), - None, - 6, - Gold, - "Name:ÿc0 %s", - isMerc ? "ÿc;Mercenary" : unit->pPlayerData->szName); + if (isMerc) + { + int act = 0; + switch (unit->dwTxtFileNo) + { + case MERC_A1: act = 1; break; + case MERC_A2: act = 2; break; + case MERC_A3: act = 3; break; + case MERC_A4: act = 4; break; + case MERC_A5: + case MERC_A5B: act = 5; break; + } + // The variant is named after the merc's aura (e.g. Act 2 Defiance), if it has one. + std::string variant = ""; + for (Skill* pSkill = unit->pInfo ? unit->pInfo->pFirstSkill : NULL; pSkill; pSkill = pSkill->pNextSkill) + { + if (pSkill->pSkillInfo && pSkill->pSkillInfo->dwFlags.bAura && HasSkillName(pSkill->pSkillInfo)) + variant = " " + GetSkillName(pSkill->pSkillInfo); + } + Texthook::Draw(column1, + (y += 8), + None, + 6, + Gold, + "Mercenary:ÿc; Act %d%s", + act, + variant.c_str()); + + // Max life only: the client's current merc life (stat 6) is a 0-128 fraction once the + // server's monster updates arrive, but absolute right after the merc spawns. + Texthook::Draw(column1, + y + 12, + None, + 6, + Gold, + L"Life:ÿc0 %d", + static_cast(D2COMMON_GetUnitStat(unit, STAT_MAXHP, 0)) >> 8); + } + else + { + Texthook::Draw(column1, + (y += 8), + None, + 6, + Gold, + "Name:ÿc0 %s", + unit->pPlayerData->szName); + } auto player_level = D2COMMON_GetUnitStat(unit, STAT_LEVEL, 0); Texthook::Draw(pRect.right - 5, @@ -873,24 +955,24 @@ void StatsDisplay::OnDraw() static_cast(maxPoison / 256.0 * poisonLength), poisonLength / 25.0); - Texthook::Draw(column1, - (y += 16), - None, - 6, - Gold, - L"Magic Find:ÿc3 %d", - static_cast(D2COMMON_GetUnitStat(unit, STAT_MAGICFIND, 0)) - ); - Texthook::Draw(column2, - y, - None, - 6, - Gold, - L"Gold Find:ÿc9 %d", - static_cast(D2COMMON_GetUnitStat(unit, STAT_GOLDFIND, 0))); - if (!isMerc) { + Texthook::Draw(column1, + (y += 16), + None, + 6, + Gold, + L"Magic Find:ÿc3 %d", + static_cast(D2COMMON_GetUnitStat(unit, STAT_MAGICFIND, 0)) + ); + Texthook::Draw(column2, + y, + None, + 6, + Gold, + L"Gold Find:ÿc9 %d", + static_cast(D2COMMON_GetUnitStat(unit, STAT_GOLDFIND, 0))); + Texthook::Draw(column1, (y += 16), None, @@ -899,6 +981,11 @@ void StatsDisplay::OnDraw() L"Stash Gold:ÿc9 %d", static_cast(D2COMMON_GetUnitStat(unit, STAT_GOLDBANK, 0))); } + else + { + // A merc's magic/gold find has no effect on drops; its skills take that space. + DrawMercSkills(unit, column1, column2, &y); + } //int cowKingKilled = D2COMMON_GetQuestFlag(D2CLIENT_GetQuestInfo(), 4, 10); //Texthook::Draw(column2, y, None, 6, Gold, @@ -936,6 +1023,125 @@ void StatsDisplay::OnDraw() } } } + + if (isMerc) + { + // The merc page's length depends on its skills: size the box to the last row drawn + // (used from the next frame on), within the screen. + unsigned int maxYSize = Hook::GetScreenHeight() - GetY(); + mercYSize = min(static_cast(y + 16 - GetY()), maxYSize); + } + } +} + +void StatsDisplay::DrawTabs(int x, int y, bool showMerc, bool hasMerc) +{ + const char* mercLabel = hasMerc ? "Mercenary" : "No mercenary"; + int characterWidth = Texthook::GetTextSize("Character", 6).x; + int separatorWidth = Texthook::GetTextSize(" | ", 6).x; + int mercWidth = Texthook::GetTextSize(mercLabel, 6).x; + int mercX = x + characterWidth + separatorWidth; + characterTab = RECT{ x - 2, y - 2, x + characterWidth + 2, y + 10 }; + mercTab = RECT{ mercX - 2, y - 2, mercX + mercWidth + 2, y + 10 }; + + RECT& activeTab = showMerc ? mercTab : characterTab; + Boxhook::Draw(activeTab.left, activeTab.top, activeTab.right - activeTab.left, activeTab.bottom - activeTab.top, 0x1F, Drawing::BTOneHalf); + Texthook::Draw(x, y, None, 6, showMerc ? Gold : White, "Character"); + Texthook::Draw(x + characterWidth, y, None, 6, Grey, " | "); + Texthook::Draw(mercX, y, None, 6, !hasMerc ? Grey : (showMerc ? White : Gold), "%s", mercLabel); +} + +void StatsDisplay::DrawMercSkills(UnitAny* pMerc, int column1, int column2, int* pY) +{ + struct MercSkill + { + std::string text; + int width; + }; + std::vector cells; + size_t auras = 0; + std::string damage = ""; + bool damageOfAttackSkill = false; + std::set attackSkills; + if (merc_attack_skills.count(pMerc->dwTxtFileNo)) + attackSkills = merc_attack_skills.at(pMerc->dwTxtFileNo); + for (Skill* pSkill = pMerc->pInfo ? pMerc->pInfo->pFirstSkill : NULL; pSkill; pSkill = pSkill->pNextSkill) + { + SkillsTxt* pTxt = pSkill->pSkillInfo; + if (!HasSkillName(pTxt)) + continue; + std::string name = GetSkillName(pTxt); + int total = D2COMMON_GetSkillLevel(pMerc, pSkill, TRUE); + int base = static_cast(pSkill->skillLevel); + char text[128]; + if (total != base) + sprintf_s(text, sizeof(text), "%s%s:ÿc0 %d (%d+%d)", pTxt->dwFlags.bAura ? "ÿc;" : "", name.c_str(), total, base, total - base); + else + sprintf_s(text, sizeof(text), "%s%s:ÿc0 %d", pTxt->dwFlags.bAura ? "ÿc;" : "", name.c_str(), total); + // Auras first, the other skills after them, each in the merc's own skill order. + MercSkill cell = { text, static_cast(Texthook::GetTextSize(text, 6).x) }; + if (pTxt->dwFlags.bAura) + cells.insert(cells.begin() + auras++, cell); + else + cells.push_back(cell); + + if (pTxt->dwFlags.bAura) + continue; + // The attack skill's damage: the skill's own physical damage (weapon damage left out) and + // its elemental damage with synergies and the merc's mastery, at the current level, as the + // engine computes them. The merc's attack skill (merc_attack_skills) wins, else the first + // skill that deals damage. + bool isAttackSkill = attackSkills.count(pTxt->wSkillId) > 0; + if (damageOfAttackSkill || (!damage.empty() && !isAttackSkill)) + continue; + int minPhys = D2COMMON_10567_GetMinPhysDamage(pMerc, pTxt->wSkillId, total, FALSE) >> 8; + int maxPhys = D2COMMON_10297_GetMaxPhysDamage(pMerc, pTxt->wSkillId, total, FALSE) >> 8; + int minElem = 0; + int maxElem = 0; + const char* elemColor = NULL; + switch (pTxt->bEType) + { + case 1: elemColor = "ÿc1"; break; // fire + case 2: elemColor = "ÿc9"; break; // lightning + case 3: elemColor = "ÿc8"; break; // magic + case 4: elemColor = "ÿc3"; break; // cold + } + if (elemColor) + { + minElem = D2COMMON_10121_GetMinElemDamage(pMerc, pTxt->wSkillId, total, TRUE) >> 8; + maxElem = D2COMMON_11091_GetMaxElemDamage(pMerc, pTxt->wSkillId, total, TRUE) >> 8; + } + if (maxPhys <= 0 && maxElem <= 0) + continue; + char text2[128] = ""; + if (maxPhys > 0 && maxElem > 0) + sprintf_s(text2, sizeof(text2), "%s:ÿc0 %d-%d %s%d-%d", name.c_str(), minPhys, maxPhys, elemColor, minElem, maxElem); + else if (maxPhys > 0) + sprintf_s(text2, sizeof(text2), "%s:ÿc0 %d-%d", name.c_str(), minPhys, maxPhys); + else + sprintf_s(text2, sizeof(text2), "%s:%s %d-%d", name.c_str(), elemColor, minElem, maxElem); + damage = text2; + damageOfAttackSkill = isAttackSkill; + } + + *pY += 8; + Texthook::Draw(column1, (*pY += 16), None, 6, Gold, "Skills (ÿc;auraÿc4):"); + if (!damage.empty()) + Texthook::Draw(column2, *pY, None, 6, Gold, "%s", damage.c_str()); + + // Two skills per row; one that is wider than half the panel gets a row of its own. + int half = column2 - column1 - 4; + bool rowOpen = false; + for (auto& cell : cells) + { + if (rowOpen && cell.width <= half) + { + Texthook::Draw(column2, *pY, None, 6, Gold, "%s", cell.text.c_str()); + rowOpen = false; + continue; + } + Texthook::Draw(column1, (*pY += 16), None, 6, Gold, "%s", cell.text.c_str()); + rowOpen = cell.width <= half; } } @@ -997,6 +1203,18 @@ bool StatsDisplay::OnClick(bool up, if (!IsMinimized() && InRange(x, y)) { + bool onCharacterTab = InTab(characterTab, x, y); + bool onMercTab = InTab(mercTab, x, y); + if (onCharacterTab || onMercTab) + { + // A click on a tab switches the page instead of closing the panel; the greyed + // "No mercenary" tab does nothing. While the merc inventory is open the choice + // only lasts until it closes. + UnitAny* pMerc = D2CLIENT_GetMercUnit(); + if (!up && (onCharacterTab || IsLivingMerc(pMerc))) + (mercUiOpen ? mercUiPage : mercTabSelected) = onMercTab; + return true; + } SetMinimized(true); return true; } diff --git a/BH/Drawing/Stats/StatsDisplay.h b/BH/Drawing/Stats/StatsDisplay.h index 91f09d81..4157f333 100644 --- a/BH/Drawing/Stats/StatsDisplay.h +++ b/BH/Drawing/Stats/StatsDisplay.h @@ -59,9 +59,15 @@ namespace Drawing static StatsDisplay* display; std::string name; unsigned int x, y, xSize, ySize; + unsigned int characterYSize, mercYSize; bool active, minimized; + bool mercTabSelected, mercUiOpen, mercUiPage; + RECT characterTab, mercTab; CRITICAL_SECTION crit; std::vector customStats; + + void DrawTabs(int x, int y, bool showMerc, bool hasMerc); + void DrawMercSkills(UnitAny* pMerc, int column1, int column2, int* pY); public: StatsDisplay(std::string name); ~StatsDisplay(); From 2b2089740446e7bc4ad4401db9e85935daf982d5 Mon Sep 17 00:00:00 2001 From: errolgr Date: Sun, 4 Oct 2026 21:39:39 -0400 Subject: [PATCH 2/2] Stats panel merc tab: skills in a box next to the panel (fits a 600 px screen); panel keeps the Character page's size --- BH/Drawing/Stats/StatsDisplay.cpp | 61 ++++++++++++++++++++----------- BH/Drawing/Stats/StatsDisplay.h | 4 +- 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/BH/Drawing/Stats/StatsDisplay.cpp b/BH/Drawing/Stats/StatsDisplay.cpp index 8a5d9e32..2e3c2efe 100644 --- a/BH/Drawing/Stats/StatsDisplay.cpp +++ b/BH/Drawing/Stats/StatsDisplay.cpp @@ -256,7 +256,6 @@ void StatsDisplay::LoadConfig() int xPos = App.general.statsOnRight.value ? *p_D2CLIENT_ScreenSizeX - 10 - GetXSize() : 10; SetX(xPos); SetYSize(height); - characterYSize = mercYSize = GetYSize(); } void StatsDisplay::SetX(unsigned int newX) @@ -303,8 +302,10 @@ bool StatsDisplay::InRange(unsigned int x, unsigned int y) { return IsActive() && - x >= GetX() && y >= GetY() && - x <= GetX() + GetXSize() && y <= GetY() + GetYSize(); + ((x >= GetX() && y >= GetY() && + x <= GetX() + GetXSize() && y <= GetY() + GetYSize()) || + (sideXSize > 0 && x >= sideX && y >= sideY && + x <= sideX + sideXSize && y <= sideY + sideYSize)); } static bool IsLivingMerc(UnitAny* pMerc) @@ -354,6 +355,7 @@ void StatsDisplay::OnDraw() { int xPos = App.general.statsOnRight.value ? *p_D2CLIENT_ScreenSizeX - 10 - GetXSize() : 10; SetX(xPos); + sideXSize = 0; // set again by DrawMercSkills (merc tab only) // The Mercenary tab shows the merc's stats. Opening the merc inventory selects it (a tab // clicked meanwhile counts until the inventory closes), then the user's tab is back. @@ -368,7 +370,6 @@ void StatsDisplay::OnDraw() unit = pMerc; isMerc = true; } - SetYSize(isMerc ? mercYSize : characterYSize); for (std::list::iterator it = Hooks.begin(); it != Hooks.end(); ++it) (*it)->OnDraw(); @@ -983,8 +984,8 @@ void StatsDisplay::OnDraw() } else { - // A merc's magic/gold find has no effect on drops; its skills take that space. - DrawMercSkills(unit, column1, column2, &y); + // A merc's magic/gold find has no effect on drops; its skills go in a box of their own. + DrawMercSkills(unit); } //int cowKingKilled = D2COMMON_GetQuestFlag(D2CLIENT_GetQuestInfo(), 4, 10); @@ -1023,14 +1024,6 @@ void StatsDisplay::OnDraw() } } } - - if (isMerc) - { - // The merc page's length depends on its skills: size the box to the last row drawn - // (used from the next frame on), within the screen. - unsigned int maxYSize = Hook::GetScreenHeight() - GetY(); - mercYSize = min(static_cast(y + 16 - GetY()), maxYSize); - } } } @@ -1051,7 +1044,9 @@ void StatsDisplay::DrawTabs(int x, int y, bool showMerc, bool hasMerc) Texthook::Draw(mercX, y, None, 6, !hasMerc ? Grey : (showMerc ? White : Gold), "%s", mercLabel); } -void StatsDisplay::DrawMercSkills(UnitAny* pMerc, int column1, int column2, int* pY) +// The merc tab's skills box, next to the main panel (the main panel has no room left on a 600 px +// high screen): every named skill with its level, auras first, and the attack skill's damage. +void StatsDisplay::DrawMercSkills(UnitAny* pMerc) { struct MercSkill { @@ -1124,25 +1119,47 @@ void StatsDisplay::DrawMercSkills(UnitAny* pMerc, int column1, int column2, int* damageOfAttackSkill = isAttackSkill; } - *pY += 8; - Texthook::Draw(column1, (*pY += 16), None, 6, Gold, "Skills (ÿc;auraÿc4):"); + unsigned int width = GetXSize(); + int left = App.general.statsOnRight.value ? static_cast(GetX()) - 10 - static_cast(width) : GetX() + width + 10; + if (left < 0) + return; + int column1 = left + 5; + int column2 = column1 + width / 2; + + // Two skills per row; one that is wider than half the box gets a row of its own. + struct Line { int y; bool column2; std::string text; }; + std::vector lines; + int dy = 8; + lines.push_back({ dy, false, "Skills (ÿc;auraÿc4):" }); if (!damage.empty()) - Texthook::Draw(column2, *pY, None, 6, Gold, "%s", damage.c_str()); - - // Two skills per row; one that is wider than half the panel gets a row of its own. + lines.push_back({ dy, true, damage }); int half = column2 - column1 - 4; bool rowOpen = false; for (auto& cell : cells) { if (rowOpen && cell.width <= half) { - Texthook::Draw(column2, *pY, None, 6, Gold, "%s", cell.text.c_str()); + lines.push_back({ dy, true, cell.text }); rowOpen = false; continue; } - Texthook::Draw(column1, (*pY += 16), None, 6, Gold, "%s", cell.text.c_str()); + lines.push_back({ dy += 16, false, cell.text }); rowOpen = cell.width <= half; } + + sideX = left; + sideY = GetY(); + sideXSize = width; + sideYSize = dy + 20; + RECT rect; + rect.left = sideX; + rect.top = sideY; + rect.right = sideX + sideXSize; + rect.bottom = sideY + sideYSize; + Boxhook::Draw(sideX, sideY, sideXSize, sideYSize, White, Drawing::BTBlack); + Framehook::DrawRectStub(&rect); + for (const Line& line : lines) + Texthook::Draw(line.column2 ? column2 : column1, sideY + line.y, None, 6, Gold, "%s", line.text.c_str()); } bool StatsDisplay::KeyClick(bool bUp, diff --git a/BH/Drawing/Stats/StatsDisplay.h b/BH/Drawing/Stats/StatsDisplay.h index 4157f333..95baa956 100644 --- a/BH/Drawing/Stats/StatsDisplay.h +++ b/BH/Drawing/Stats/StatsDisplay.h @@ -59,7 +59,7 @@ namespace Drawing static StatsDisplay* display; std::string name; unsigned int x, y, xSize, ySize; - unsigned int characterYSize, mercYSize; + unsigned int sideX = 0, sideY = 0, sideXSize = 0, sideYSize = 0; // the merc tab's skills box (0 size: not drawn) bool active, minimized; bool mercTabSelected, mercUiOpen, mercUiPage; RECT characterTab, mercTab; @@ -67,7 +67,7 @@ namespace Drawing std::vector customStats; void DrawTabs(int x, int y, bool showMerc, bool hasMerc); - void DrawMercSkills(UnitAny* pMerc, int column1, int column2, int* pY); + void DrawMercSkills(UnitAny* pMerc); public: StatsDisplay(std::string name); ~StatsDisplay();