Compare commits

...
9 Commits
Author SHA1 Message Date
Brues cdb515e0c3 Remove third compare tooltip support
Drop legacy third shopping tooltip handling from GameTooltip_ShowCompareItem. Unpack only two shoppingTooltips, remove unused item3/shoppingTooltip3 variables and commented code, and simplify anchor/owner logic for the two-compare layout. GAP/backdrop spacing calculation is preserved.
2026-07-03 02:27:11 -05:00
Brues d55613e88c Clear the tooltip item GUID on every clear (fix stale GetItem)
GameTooltip:GetItem() returned the previous item after SetUnitAura /
SetEquipmentSet (and any tooltip that shows no item). The engine's per-tooltip
clear FUN_00530050 zeroes the unit / gameobject / itemID / spell identity fields
but leaves the item GUID (+0x380/+0x384) intact -- the field CGItem tooltips
(SetBagItem/SetInventoryItem) set and that Item::TooltipItem::CurrentID resolves
when the itemID field is 0. So a stale GUID lingered across a switch to non-item
content and GetItem/HasItem/IsEquippedItem resolved the old item.

Co-hook FUN_00530050 and zero +0x380/+0x384 after the original. All 14
Set*/builder paths route through this clear (verified via xrefs; the aura
builder FUN_0052f880 and the item builder FUN_0052b650 both call it), and the
item builder calls it before writing the GUID -- so every fresh tooltip resets
the item GUID and only a real item build re-sets it. The hook runs no Lua, just
zeroes 8 bytes; the CurrentID unit/GO/spell gating is now belt-and-suspenders.
Verified in-game: bag/equipped items still resolve; SetUnitAura / SetEquipmentSet
no longer leak the previous item.
2026-07-03 02:24:50 -05:00
Brues 3c0bf3abed OnTooltipSetItem: fire via the inner invoker + restore the Lua stack
The initial fire used FUN_00702690, whose exec-context mutation (DAT_00ceeac0)
is only valid from the engine's top-level frame-script dispatch; firing it from
inside the item builder (mid-Script_Set*, and in the wild under a stack of addon
Set* wrappers) raised through the outer wrappers. Two fixes make the fire safe
for normal handlers:

- Call the inner invoker FUN_00704d50(handler, tooltip) directly. It binds the
  global `this` = tooltip and runs the handler under its own protected pcall
  (0 args), self-contained and context-independent.
- Snapshot lua_gettop before the fire and lua_settop back after, so the still-
  running outer C code sees exactly the stack it left. Without this the invoker
  left the stack shifted and the outer code / other DLLs' hooks indexed a stale
  slot -> ACCESS_VIOLATION.

Verified in-game: SetScript / HookScript / GetScript all work, `this` is the
tooltip (a1 is nil -- vanilla convention, not modern `self`), and no-op /
print / AddLine handlers fire cleanly on bag / equipped / merchant / auction.

Docs: correct the handler example to use `this` (not a `self` arg), and add the
caveat that re-entrant tooltip rebuilds from the handler (ShowCompareItem, Set*
on another tooltip) can crash in a hook-heavy client and must be deferred
(C_Timer.After(0, ...)) -- the event fires mid-build.
2026-07-02 23:18:17 -05:00
Brues 3179d1741e Add OnTooltipSetItem frame script
Backports the modern OnTooltipSetItem tooltip script to 1.12 as a real
frame script, settable via the standard SetScript / GetScript / HookScript
and firing whenever a tooltip's item is set (with the tooltip as self). Lets
addons annotate item tooltips by hooking one script instead of wrapping every
Set* method -- the destructive wrap-and-rebuild pattern (AtlasLoot's
AddSourceLine) that clobbers the tooltip's item linkage.

Fully native, two Game::HookAutoRegister co-hooks in Tooltip::SetItemEvent:

- Resolver (FUN_005295d0, the CGGameTooltip vtable script-name -> slot
  vmethod): for "OnTooltipSetItem" (a name the engine doesn't know) return a
  pointer to a C-side per-tooltip 8-byte {handler, context} cell. The tooltip
  object (alloc size 0x460) has no free slot, and the resolver's only contract
  is "return where the handler lives", so external storage behaves exactly
  like the engine's own slots -- proven by OnTooltipCleared (+0x44c).
- Item builder (FUN_0052B650, the shared funnel for SetBagItem /
  SetInventoryItem / SetHyperlink / SetMerchantItem / SetAuctionItem / ...):
  after the build, fire the handler via FUN_00702690 (the no-arg runner the
  clear uses for OnTooltipCleared). Recursion-guarded, and suppressed via
  Tooltip::SetItemEvent::Suppressor during Tooltip::Compare's internal
  equipped-item build so a handler doesn't run mid line-shift.

Item only for now; OnTooltipSetSpell / OnTooltipSetUnit follow the same
pattern with their own builders.
2026-07-02 19:41:29 -05:00
Brues 63c003db26 Add GameTooltip:IsEquippedItem; share tooltip-item resolver; fix GetItem for CGItem/auction tooltips
Introduce Item::TooltipItem::CurrentID -- the single "what item is this
tooltip showing" resolver. It trusts the link-path itemID (+0x398), and
otherwise resolves the CGItem GUID (+0x380) but only when no unit /
gameobject / spell id is set, since the clear does not zero the item GUID
and it goes stale after the tooltip moves on.

- GameTooltip:GetItem / HasItem now route through CurrentID: fixes GetItem
  returning nil for CGItem tooltips (bag / equipped / merchant / loot) and
  HasItem false-positiving on a unit tooltip after an item hover.
- Add GameTooltip:IsEquippedItem() (3.3.5 backport, registry index 63),
  resolving the displayed item via CurrentID + the equipment-slot walk.
- SetHyperlinkCompareItem: the comparisonTooltip (4th arg) fallback uses
  CurrentID; stat deltas now show by default and hide only for an explicit
  false / 0 (was: show only for a truthy shift).
- GetItem round-trips the random-suffix id in the link for auction/compare
  items (no CGItem instance) via the compare descriptor.
- FrameXML GameTooltip_ShowCompareItem falls back to self.compareLink, so a
  caller can hand it an explicit link when the tooltip's item state was
  rebuilt out from under it (e.g. an AH addon appending source lines).
- Offsets: compare-descriptor + FontString offsets; drop the duplicate
  tooltip item-id/guid offsets in favor of the canonical OFF_TOOLTIP_*.
2026-07-02 19:09:03 -05:00
Brues d01e51d862 Add GameTooltip:SetHyperlinkCompareItem (item comparison tooltips)
Backports the 3.3.5+ shopping-tooltip method, fully native (no lua_pcall):
fills a tooltip with the item currently equipped in the given item's slot
— a grey "Currently Equipped" header, the equipped item's tooltip, then a
green/red per-stat delta breakdown gated on shiftButton.

- Extract the stat engine (base record + on-equip-spell auras + random
  suffix) out of item/Stats.cpp into the shared Item::StatAccum, reused by
  C_Item.GetItemStats/GetItemStatDelta and the compare deltas.
- Build via the engine's own item-tooltip builder; prepend the grey header
  by shifting the line FontStrings, replicating the show/hide protocol,
  keying on the visible flag (no stale text) and resetting cell color.
- Delta colors sourced from ColorData.h via a new constexpr ByTag helper.
- offset selects the slot for two-slot items (rings/trinkets/1H weapons);
  shiftButton gates the deltas (true/1 vs false/0/nil); a comparisonTooltip
  4th arg lets it run link-free, 1:1 with the retail call.
- FrameXML GameTooltip_ShowCompareItem consumer with a dynamic,
  backdrop-derived anchor gap.
2026-07-02 13:41:10 -05:00
Brues 92d868424c Lift GameTooltip's 30-line cap (pure C++)
Vanilla GameTooltip:AddLine silently drops any line past the 30
TextLeftN/TextRightN FontStrings the FrameXML template declares, so
stat-heavy tooltips (pfUI eqcompare, AtlasLoot) lose their tail.

Tooltip::LinePool co-hooks the tooltip's XML-OnLoad region scan
(FUN_00529650, CGameTooltip vtable slot 9) via HookAutoRegister. After
the engine sets up the template's 30 lines it creates TextLeft/Right
31..60 in C++ (alloc from the CSimpleFontString pool -> ctor(parent) ->
name -> font copied from the previous line -> anchor) and appends each
pair to the tooltip's three line arrays, bumping numLinesAllocated. Runs
once per GameTooltip-family frame at creation and per /reload, so it
auto-covers GameTooltip, ShoppingTooltip1/2, ItemRefTooltip, AtlasLoot,
WorldMapTooltip, etc. No addon Lua and no per-line AddLine hook.

SetPoint stores offsets in internal coordinates, not pixels, so the
anchor offsets are scaled the way Script_SetPoint does
(pixel * [0x00832A44] / ([0x00832A4C] * 1024)) — without that a -2px gap
rendered ~3950px off.

Adds the tooltip line-pool, FontString-creation, and UI-scale offsets to
Offsets.h; documents the behavior in the README and marks TODO #97 done.
2026-07-02 00:08:30 -05:00
Brues eb4c42b1e0 Update TODO.md 2026-07-01 22:28:37 -05:00
Brues 2968f51661 GetItemStats: cover Turtle equip-spell stats; exact effect value
Cross-checked against Turtle's server source (patch_1172): ItemModType
is unchanged vanilla (0-7) and ApplyItemEquipSpell applies only
ON_EQUIP, confirming special stats are all equip-spell auras.

- Value now uses the engine's CalculateSimpleValue = EffectBasePoints +
  EffectBaseDice (new OFF_SPELL_RECORD_EFFECT_BASE_DICE = 0x10C) instead
  of a hardcoded +1; equal for the fixed-die stat auras but exact.
- Decode the additional stat auras Turtle puts on custom gear: dodge %
  (49), parry % (47), block chance % (51), spell crit % (57/71), and
  flat health (34) / mana (35) folded into the existing keys. Adds shield
  block value from the record's m_block. New keys ITEM_MOD_DODGE_RATING,
  ITEM_MOD_PARRY_RATING, ITEM_MOD_BLOCK_RATING, ITEM_MOD_CRIT_SPELL_RATING,
  ITEM_MOD_BLOCK_VALUE (percent stats keep vanilla-native values).

Documents the expanded key set in docs/API.md.
2026-07-01 22:27:42 -05:00
18 changed files with 2334 additions and 412 deletions
+89
View File
@@ -1,3 +1,92 @@
function GameTooltip_Hide()
GameTooltip:Hide()
end
GameTooltip.shoppingTooltips = GameTooltip.shoppingTooltips or { ShoppingTooltip1, ShoppingTooltip2 }
function GameTooltip_ShowCompareItem(self, shift)
if ( not self ) then
self = GameTooltip;
end
local item, link = self:GetItem();
link = link or self.compareLink
if ( not link ) then
return;
end
local shoppingTooltip1, shoppingTooltip2 = unpack(self.shoppingTooltips or { ShoppingTooltip1, ShoppingTooltip2 });
local SEPARATION = 6;
local backdrop = shoppingTooltip1.GetBackdrop and shoppingTooltip1:GetBackdrop();
local GAP = SEPARATION + ((type(backdrop) == "table" and backdrop.edgeSize) or 0);
local item1 = nil;
local item2 = nil;
local side = "left";
if ( shoppingTooltip1:SetHyperlinkCompareItem(link, 1, shift, self) ) then
item1 = true;
end
if ( shoppingTooltip2:SetHyperlinkCompareItem(link, 2, shift, self) ) then
item2 = true;
end
-- find correct side
local rightDist = 0;
local leftPos = self:GetLeft();
local rightPos = self:GetRight();
if ( not rightPos ) then
rightPos = 0;
end
if ( not leftPos ) then
leftPos = 0;
end
rightDist = GetScreenWidth() - rightPos;
if (leftPos and (rightDist < leftPos)) then
side = "left";
else
side = "right";
end
-- see if we should slide the tooltip
if ( self:GetAnchorType() and self:GetAnchorType() ~= "ANCHOR_PRESERVE" ) then
local totalWidth = 0;
if ( item1 ) then
totalWidth = totalWidth + shoppingTooltip1:GetWidth();
end
if ( item2 ) then
totalWidth = totalWidth + shoppingTooltip2:GetWidth();
end
if ( (side == "left") and (totalWidth > leftPos) ) then
self:SetAnchorType(self:GetAnchorType(), (totalWidth - leftPos), 0);
elseif ( (side == "right") and (rightPos + totalWidth) > GetScreenWidth() ) then
self:SetAnchorType(self:GetAnchorType(), -((rightPos + totalWidth) - GetScreenWidth()), 0);
end
end
if ( item1 ) then
shoppingTooltip1:SetOwner(self, "ANCHOR_NONE");
shoppingTooltip1:ClearAllPoints();
if ( side and side == "left" ) then
shoppingTooltip1:SetPoint("TOPRIGHT", self, "TOPLEFT", -GAP, -10);
else
shoppingTooltip1:SetPoint("TOPLEFT", self, "TOPRIGHT", GAP, -10);
end
shoppingTooltip1:SetHyperlinkCompareItem(link, 1, shift, self);
shoppingTooltip1:Show();
if ( item2 ) then
shoppingTooltip2:SetOwner(shoppingTooltip1, "ANCHOR_NONE");
shoppingTooltip2:ClearAllPoints();
if ( side and side == "left" ) then
shoppingTooltip2:SetPoint("TOPRIGHT", shoppingTooltip1, "TOPLEFT", -GAP, 0);
else
shoppingTooltip2:SetPoint("TOPLEFT", shoppingTooltip1, "TOPRIGHT", GAP, 0);
end
shoppingTooltip2:SetHyperlinkCompareItem(link, 2, shift, self);
shoppingTooltip2:Show();
end
end
end
+1
View File
@@ -6,6 +6,7 @@ FOCUSTARGET = "Focus Target";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "g";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEM_DELTA_DESCRIPTION = "If you replace this item, the following stat changes will occur:";
ITEMS_EQUIPPED = "%d |4item:items; equipped";
ITEMS_IN_INVENTORY = "%d |4item:items; in inventory";
ITEMS_NOT_IN_INVENTORY = "%d |4item:items; missing";
+9 -1
View File
@@ -36,7 +36,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID` |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem` (script) |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
@@ -165,6 +165,14 @@ native targeting bindings instead of orphaned at the bottom.
See [Focus / Bindings](docs/API.md#bindings-focustarget--targetfocus) for the implementation note.
### Behaviors
Transparent engine tweaks — no API to call, they just fix a vanilla limitation:
| Tweak | Effect |
|-------|--------|
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
## Installation
Download the prebuilt `ClassicAPI.dll` from the
+269
View File
@@ -3301,3 +3301,272 @@ the matching read. Both should be straightforward once the field
offset is identified — 3.3.5's `Script_*` equivalents are a good
starting point for what the parameter shape and bounds-clamp
behavior should be.
## ~~97. Lift GameTooltip's 30-line cap~~ — DONE
Shipped as [src/tooltip/LinePool.cpp](src/tooltip/LinePool.cpp): pure C++,
no addon Lua. Co-hooks the XML-OnLoad region scan (FUN_00529650, vtable
slot 9) via `Game::HookAutoRegister`; after the engine sets up the
template's 30 lines it creates `TextLeft/Right 31..60` in C++ (alloc →
ctor → name → font-copied-from-prev → anchor, offsets scaled pixel →
internal like Script_SetPoint) and appends them to the tooltip's three
line arrays, bumping `numLinesAllocated`. Runs once per GameTooltip-family
frame at creation + per `/reload`, so it auto-covers GameTooltip,
ShoppingTooltip1/2, ItemRefTooltip, AtlasLootTooltip, WorldMapTooltip, …
Verified in-game: AtlasLoot tooltip rendered a 31st line correctly. Cap is
`kMaxLines = 60`. The design notes below record the RE trail; Approach A
(hook AddLine) / Approach B (addon Lua) were both explored and dropped in
favor of the scan-hook.
Vanilla 1.12's `GameTooltip:AddLine` silently drops any line past the
pool of `TextLeftN`/`TextRightN` FontStrings the XML template
pre-declares (30). Modern (3.3.5+) engines grow the pool on demand.
Addons that append comparison summaries, item-info footers, or aura
details to a full item tooltip get their tail chopped. Practical
example — pfUI's eqcompare block (base-stat deltas inline + extended
stats appended at the bottom) hits the cap on stat-heavy items where
the vendor tooltip already has ~22 lines and the compare block adds
another 10; extended-stat rows past line 30 disappear silently.
Also affects `ShoppingTooltip1`/`ShoppingTooltip2` (same
`GameTooltipTemplate`).
### Root cause (verified in Ghidra on the 1.12 Octo binary)
`AddLine` internal impl at `FUN_00530270` — the shared body called
from both `Script_GameTooltipAddLine` (`FUN_00531630`, method ptr
stored in the GameTooltip method table alongside the `"AddLine"`
string at `0x00854560`) and `Script_GameTooltipAddDoubleLine`:
```c
void FUN_00530270(int this, char *leftText, char *rightText,
colorLeft, colorRight, wrapFlag) {
if ((*(int *)(this + 0x31c) != *(int *)(this + 0x320)) && anyHasText) {
// set leftText on textLeft[numLines_used],
// set rightText on textRight[numLines_used],
// wrapFlag[numLines_used] = wrapFlag,
// ++numLines_used
}
// else: silently drop the line
}
```
`0x320 == 800` decimal, matches decompile. Pool exhausted ⇒ nothing
happens.
### CGameTooltip struct offsets (1.12)
Derived from `FUN_00530270` and the pool-init at `FUN_00529650`:
| Offset | Field |
|--------|----------------------------------------------------|
| `+0x31c` | `numLines` — current write index (0-based) |
| `+0x320` | `numLinesAllocated` — pool size (30 default) |
| `+0x328..+0x32c` | `textLeft[]` — array of `CSimpleFontString *`, count `+0x320` |
| `+0x334..+0x338` | `textRight[]` — same, symmetric right column |
| `+0x340..+0x344` | `wrapFlag[]` — per-line wrap bool |
Actual layout of the array slots: `+0x328` is the first ptr in an
external heap block whose base is `param_1[0xcb]` = `+0x32c`. The
init function reallocates all three arrays whenever `numLinesAllocated`
would change — see `FUN_00536c80` (the reallocator) and `FUN_004368c0`
(a matching alloc for the wrap-flag array of a different element size).
### The pool-init function
`FUN_00529650` — walks `%sTextLeftN` / `%sTextRightN` FontString names
from the FrameXML-declared pool, counts them, reallocates the three
arrays, stores the pointers. Called via vtable slot at `0x00808f84`
(only xref) — likely `SetOwner` or an equivalent virtual reset method
runs it. It's the natural growth path — if we can force it to see a
larger pool of Lua-visible FontStrings and trigger it, the arrays
resize themselves.
### The 3.3.5 reference (already REd)
3.3.5's `GameTooltip::AddLine` at `0x0061fec0` has the grow-on-demand
branch we want to backport. Shape:
```c
if (currentLine == numLinesAllocated - 1) {
format(buf, 256, "%sTextLeft%d", tooltipName, currentLine + 1);
auto *fs = SimpleFontString::create(this, /*layer*/2, /*subLayer*/1);
fs->setName(buf);
fs->setFontObject(prevLeft->fontObject);
fs->anchor(TOPLEFT, prevLeft, BOTTOMLEFT, 0.0, calculatedYOffset);
// ... mirror for TextRight ...
// ... append fs to textLeft[]/textRight[] at [numLinesAllocated],
// then ++numLinesAllocated.
}
// fall through to the existing "set text on line[currentLine]" path
```
3.3.5 helpers (offsets 3.3.5-specific — 1.12 equivalents need to be
re-derived):
- `FUN_00485240` — SimpleFontString constructor `(parent, layer, sublayer)`
- `FUN_0048b6c0` — set-name helper (registers under global `_G[name]`)
- `FUN_0048a260` — anchor helper `(this, anchorPoint, relativeTo, relativePoint, xoff, yoff)`
Struct offsets in 3.3.5 were `+0x2a4` (numLines) / `+0x2a8` (numAlloc) /
`+0x2b4` (textLeft) / `+0x2c0` (textRight) / `+0x2cc` (wrapFlag). All
shifted vs. 1.12 (see above), unsurprising given TBC/WotLK bloat.
### Implementation plan
**Approach A — pre-hook AddLine with a grow-if-needed prelude
(preferred)**:
1. Locate the 1.12 equivalents of the three 3.3.5 helpers. Trace them
from existing `CSimpleFontString`-creating sites in the engine —
the CreateFontString API dispatch is a good starting point (it's
Lua-visible and calls the same constructor).
2. Locate the array reallocators used by `FUN_00529650`
(`FUN_00536c80` for the two pointer arrays, `FUN_004368c0` for the
wrap-flag byte array).
3. MinHook `FUN_00530270`. Pre-hook body:
- If `numLines != numLinesAllocated`, call original — done.
- Otherwise: realloc the three arrays to `numLinesAllocated + 1`,
create two new `CSimpleFontString` (left + right), name them
`"%sTextLeft%d"` / `"%sTextRight%d"` with `tooltip->GetName()` and
the new index, anchor each below the prior-slot FontString on the
same side, use the prior FontString's `fontObject` (`+0xd8` in the
3.3.5 layout — confirm on 1.12) as the default, write the pointers
into `textLeft[numLinesAllocated]` / `textRight[numLinesAllocated]`,
zero-init `wrapFlag[numLinesAllocated]`, and bump
`numLinesAllocated`.
- Then tail-call the original — it now succeeds normally.
**Approach B — pre-declare a wider pool via Lua** (fallback if the
constructor helpers turn out to be too tangled):
1. In `!!!ClassicAPI` addon, at load time, call
`GameTooltip:CreateFontString("GameTooltipTextLeft" .. i, "ARTWORK",
"GameTooltipText")` for `i = 31..60`, plus `TextRightN`, plus set
anchors matching the pattern in the template.
2. Force the engine's pool-init to re-scan. If we can't reach the
vtable trigger cleanly, expose a small C entrypoint that walks the
pool the same way `FUN_00529650` does — takes `tooltip` as arg,
reallocates arrays, refills.
Approach B is less code but ties the cap to a fixed number (60 lines
is enough for pfUI's use case but arbitrary). Approach A grows on
demand like retail and has no ceiling.
### Testing
- Hover a random-suffix weapon with several equip-spell bonuses (Krol
Blade, Ironfoe, any ZG/AQ trinket) — tooltip should render >30 lines
without truncation.
- Hover an item that produces a 40+ line tooltip when pfUI's eqcompare
block is enabled (Priest Judgement Set chest against another
Judgement chest: item tooltip ~22 lines + compare block ~10 lines).
- Verify no crash / no visual glitches on:
- ShoppingTooltip1/2 (same class, same `GameTooltipTemplate`)
- Rapid re-hovers (create/destroy pressure on the added FontStrings)
- Item tooltips that stay under 30 lines (pre-existing behavior unchanged)
### Risks
- If the array reallocation logic in `FUN_00536c80` doesn't null-out
the tail on grow, `textLeft[N]`/`textRight[N]` might contain
garbage for a frame — anchor before write, or memset to zero.
- FontString lifetime: 3.3.5 leaks the extras for the tooltip
lifetime (they stay hidden when unused). GameTooltip is a
singleton, so leak is bounded. Fine.
- Interaction with `pfUI`'s existing tooltip skinning (frame borders,
status bar reposition, etc.): the layer we're modifying is beneath
the skinning; shouldn't touch anything visible unless anchor math
is wrong.
### Related
If we ever consider dropping the whole "GameTooltipTemplate declares
30 FontStrings in XML" scheme and instead having the engine start
with 0 and grow entirely on demand — that would be even cleaner, but
requires patching every consumer of the initial pool (there aren't
many, but they need audit). Filed for future if the incremental grow
turns out to be fragile.
### Investigation update (resolved unknowns)
Traced the pool machinery end-to-end. The open questions above are now
answered, and they point away from both the AddLine hook (Approach A)
and the "trigger the engine's scan" idea — a **hybrid** is cleaner than
either.
**The scan is virtual slot 9, run only at XML-template instantiation.**
`FUN_00529650` is slot 9 (`vtable+0x24`) of the CGameTooltip vtable
(base `0x00808f60`; the null at `0x00808f5c` is the boundary, and slot 2
`0x005368d0` is the `tooltip:method()` dispatcher — confirms the class).
The two vtable-base xrefs are the constructors `FUN_00529240` /
`FUN_00529480`. The scan's prologue calls
`FUN_0076a2f0(node) → FUN_0076a060(node)`, which reads the XML
`"inherits"` attribute and child `"Frames"` — i.e. the scan is the
frame's **XML OnLoad / region-setup** virtual. It runs once when
`GameTooltip` is built from `GameTooltipTemplate` (and again per
`/reload`), **not** on `SetOwner`/show. So we can't cheaply re-trigger
it — the `node` arg is a live XML-parse handle we don't have, and
calling it with a bogus node runs the inherits/Frames path on garbage.
**The scan finds lines by `_G[name]`, so Lua-created FontStrings count.**
`numLinesAllocated` (`+0x320`) is literally "how many contiguous
`%sTextLeftN` **and** `%sTextRightN` exist by name": pass 0 counts (stop
at the first missing index), pass 1 reallocs the three arrays to that
count and fills the pointers. The name lookup `FUN_0076c760` does
`lua_pushstring(name); gettable(_G); if type==TABLE: FrameScript_GetObject`
— a plain `_G[name]` resolve, so a FontString made with
`GameTooltip:CreateFontString("GameTooltipTextLeft31", …)` **is**
visible to it. (Names must be contiguous and exist in *both* columns.)
**The array reallocators are simple and callable from C.**
`FUN_00536c80` (pointer arrays) and `FUN_004368c0` (wrap-flag array) are
the same routine with different SMem tags:
`__thiscall(descriptor, newCount)` where the descriptor is
`{ count@+0x0, cap@+0x4, data@+0x8 }` — reallocs `data` to `newCount*4`
and copies the old contents. In the tooltip the three descriptors are:
| Descriptor `this` | count / cap / data | array |
|-------------------|--------------------|-------|
| `tooltip + 0x324` | `+0x324 / +0x328 / +0x32c` | `textLeft[]` (`CSimpleFontString*`) |
| `tooltip + 0x330` | `+0x330 / +0x334 / +0x338` | `textRight[]` |
| `tooltip + 0x33c` | `+0x33c / +0x340 / +0x344` | `wrapFlag[]` (int, 4-byte) |
`numLinesAllocated` (`+0x320`) is a separate field the scan sets equal
to the count; AddLine (`FUN_00530270`) gates on
`numLines(+0x31c) != numLinesAllocated(+0x320)`.
**Recommended approach — Lua creates FontStrings, C grows the arrays
(no hook, no XML re-run).** Replicate the scan's pass 1 directly:
1. **Lua (`!!!ClassicAPI`), at load / `PLAYER_LOGIN`:** for each frame on
`GameTooltipTemplate` (`GameTooltip`, `ShoppingTooltip1/2`,
`ItemRefTooltip`, …), create `<name>TextLeft31..N` and
`<name>TextRight31..N` via `frame:CreateFontString(nm, "ARTWORK",
"GameTooltipText")`, each anchored `TOPLEFT`/`TOPRIGHT` to the prior
line's `BOTTOMLEFT`/`BOTTOMRIGHT` (mirror the template's chain).
This avoids re-deriving the 1.12 FontString ctor / setName / anchor
helpers that Approach A needed — Lua already wraps them.
2. **C entrypoint** `GrowTooltipPool(tooltipObj, newCount)` (expose as a
Lua global, called once per frame right after step 1):
- `FUN_00536c80(tooltip+0x324, newCount)`; set `+0x328 = newCount`.
- `FUN_00536c80(tooltip+0x330, newCount)`; set `+0x334 = newCount`.
- `FUN_004368c0(tooltip+0x33c, newCount)`; set `+0x340 = newCount`.
- for `i` in `[oldAlloc, newCount)`: resolve `<name>TextLeftN`/
`RightN` via the `_G[name]` dance (same as `FUN_0076c760`), and if
either is missing stop at `i`; else write the two pointers into the
new `textLeft`/`textRight` data slots and zero `wrapFlag[i]`.
- set `numLinesAllocated (+0x320) = i`.
Resolve `tooltipObj` from the Lua frame the same way our other
tooltip methods do (`FrameScript_GetObject`).
Runs once per frame per session (re-runs on `/reload`, which recreates
the frames → scan resets to 30 → our addon re-creates + re-grows). No
per-line hook (dodges the MinHook-collision risk on a moderately hot
function), no XML-node fabrication.
Remaining risk to validate in-game: whether AddLine's downstream layout
/ `Show` sizing walks `numLines` FontStrings by their anchors (expected)
vs. any hidden fixed-30 assumption elsewhere; and that the Lua-created
lines inherit height/spacing identical to the template's 1..30 so the
tooltip grows seamlessly. Both are observable on the first >30-line
hover.
+120 -1
View File
@@ -139,6 +139,9 @@ build instructions.
- [`GameTooltip:SetSpellByID(spellID)`](#gametooltipsetspellbyidspellid)
- [`GameTooltip:SetTalentByID(talentID)`](#gametooltipsettalentbyidtalentid)
- [`GameTooltip:SetInventoryItemByID(itemID)`](#gametooltipsetinventoryitembyiditemid)
- [`GameTooltip:SetHyperlinkCompareItem("itemLink" [, offset, shiftButton, comparisonTooltip])`](#gametooltipsethyperlinkcompareitemitemlink--offset-shiftbutton-comparisontooltip)
- [`GameTooltip:IsEquippedItem()`](#gametooltipisequippeditem)
- [`OnTooltipSetItem` script](#ontooltipsetitem-script)
- [`GameTooltip:SetItemByGUID(itemGUID)`](#gametooltipsetitembyguiditemguid)
- [`GameTooltip:SetEquipmentSet(name)`](#gametooltipsetequipmentsetname)
- [`GameTooltip:GetItem()`](#gametooltipgetitem)
@@ -3421,6 +3424,117 @@ end
GameTooltip:Show()
```
### `GameTooltip:SetHyperlinkCompareItem("itemLink" [, offset, shiftButton, comparisonTooltip])`
Fills the tooltip with the item **currently equipped** in the slot the
given item would occupy — a grey `Currently Equipped` header, the
equipped item's own tooltip, then (when `shiftButton` is set) a
green/red per-stat delta breakdown of how the given item compares.
This is the method behind item-comparison ("shopping") tooltips;
`FrameXML`'s `GameTooltip_ShowCompareItem` drives it.
Call it on the tooltip that should show the comparison (typically
`ShoppingTooltip1`/`ShoppingTooltip2`, but any `GameTooltipTemplate`
frame works).
| Arg | Meaning |
|-----|---------|
| `itemLink` | The item being compared (chat link, `item:` string, or bare itemID). Optional if `comparisonTooltip` is given. |
| `offset` | 1-based slot selector for two-slot items — rings, trinkets, and one-hand weapons expose `offset` 1 and 2 (Finger1/2, Trinket1/2, MainHand/OffHand). Default 1. |
| `shiftButton` | Gates the stat-change breakdown. Deltas show by default (`true`/`1`/`nil`/omitted) and are hidden only for an explicit `false`/`0` (header + equipped item only). Both the boolean and vanilla `1`/`nil` conventions are accepted. |
| `comparisonTooltip` | Optional. When `itemLink` is omitted, the compared item is taken from whatever this tooltip is displaying (matches the retail tooltip-to-tooltip call). |
**Returns** the number of comparison slots for the item (1, or 2 for
rings/trinkets/one-hand weapons; 0 if it isn't equippable, isn't
cached, or nothing is equipped in the chosen slot) — so a caller knows
whether a second `offset` is worth querying.
The stat deltas cover the same keys as
[`C_Item.GetItemStats`](#c_itemgetitemstatsitemlink) (base stats,
resistances, weapon DPS, and on-equip-spell bonuses like crit / attack
power / spell power), colored with the client's
`INCREASE_STAT_COLOR` / `DECREASE_STAT_COLOR`. The method only
populates the tooltip; the caller shows/anchors it (as retail's
`SetHyperlinkCompareItem` does).
```lua
-- Compare a hovered bag item against what's equipped:
local link = GetContainerItemLink(bag, slot)
ShoppingTooltip1:SetOwner(GameTooltip, "ANCHOR_NONE")
local slots = ShoppingTooltip1:SetHyperlinkCompareItem(link, 1, true)
ShoppingTooltip1:Show()
if slots == 2 then -- ring/trinket/1H: also compare the other slot
ShoppingTooltip2:SetOwner(GameTooltip, "ANCHOR_NONE")
ShoppingTooltip2:SetHyperlinkCompareItem(link, 2, true)
ShoppingTooltip2:Show()
end
```
### `GameTooltip:IsEquippedItem()`
Returns `true` when the item the tooltip is **currently displaying** is
equipped in one of the player's character-pane slots (1..19). Backports
the 3.3.5 `GameTooltip:IsEquippedItem` method.
Works regardless of how the tooltip was populated — link paths
(`SetItemByID`, `SetHyperlink`) and CGItem paths (`SetInventoryItem`,
`SetBagItem`, `SetItemByGUID`, …) both resolve to the displayed item.
Returns `false` for a tooltip that isn't showing an item (e.g. a spell
tooltip) with no false positives.
```lua
GameTooltip:SetInventoryItem("player", INVSLOT_HAND)
if GameTooltip:IsEquippedItem() then ... end -- true
GameTooltip:SetBagItem(0, 1)
if GameTooltip:IsEquippedItem() then ... end -- false unless that item is also worn
```
### `OnTooltipSetItem` script
A real frame script — settable with the standard `SetScript` / `GetScript` /
`HookScript` — that fires whenever a tooltip's **item** is set. Backports the
modern tooltip script so addons annotate item tooltips by hooking one script
instead of wrapping every `Set*` method.
Fires for every item-setting path (`SetBagItem`, `SetInventoryItem`,
`SetHyperlink` for an `item:` link, `SetMerchantItem`, `SetAuctionItem`,
`SetItemByID`, …). Available on all GameTooltip-type frames (`GameTooltip`,
`ItemRefTooltip`, `ShoppingTooltip1/2`, `AtlasLootTooltip`, …).
The handler receives the tooltip as the **global `this`**, the 1.12
frame-script convention — *not* a `self` argument (like every built-in vanilla
script: `OnShow`, `OnEvent`, `OnTooltipCleared`, …). Modern-style
`function(self) self:… end` handlers will see `self == nil`; use `this`.
```lua
GameTooltip:HookScript("OnTooltipSetItem", function()
local name, link, id = this:GetItem() -- the tooltip is the global `this`
if id then
this:AddLine("ID: " .. id, 0.6, 0.6, 0.6)
end
end)
```
Vanilla only shipped `OnTooltipAddMoney` / `OnTooltipCleared` /
`OnTooltipSetDefaultAnchor`; `OnTooltipSetSpell` / `OnTooltipSetUnit` are not
yet provided.
**Caveat — the event fires from inside the item-tooltip build.** Lightweight
handler work is fine: reading `this:GetItem()`, `this:AddLine(...)`, printing,
etc. But avoid *re-entrant* tooltip rebuilds from the handler — e.g.
`GameTooltip_ShowCompareItem()`, or a `Set*` call on another tooltip. Because
the handler runs mid-build, re-entering the tooltip / FrameScript machinery can
collide with other DLLs that hook the same Lua paths (nampower, SuperWoW,
weirdutils, …) and crash the client. If you need heavy work like that, defer it
to the next frame:
```lua
GameTooltip:HookScript("OnTooltipSetItem", function()
C_Timer.After(0, function() GameTooltip_ShowCompareItem() end)
end)
```
### `GameTooltip:SetEquipmentSet(name)`
Fills the tooltip with a summary of the named equipment set: header,
@@ -4738,7 +4852,7 @@ what the item actually grants — not just its stored stat slots:
| Key | Stat | Source |
|-----|------|--------|
| `ITEM_MOD_STRENGTH_SHORT` / `_AGILITY_` / `_STAMINA_` / `_INTELLECT_` / `_SPIRIT_SHORT` | Base attributes | record / `MOD_STAT` |
| `ITEM_MOD_MANA_SHORT` / `ITEM_MOD_HEALTH_SHORT` | Mana / health on equip | record |
| `ITEM_MOD_MANA_SHORT` / `ITEM_MOD_HEALTH_SHORT` | Mana / health on equip | record / `MOD_INCREASE_ENERGY` (35) / `MOD_INCREASE_HEALTH` (34) |
| `RESISTANCE0_NAME` | Armor | record / `MOD_RESISTANCE` bit 0 |
| `RESISTANCE1_NAME` … `RESISTANCE6_NAME` | Holy / Fire / Nature / Frost / Shadow / Arcane resistance | record / `MOD_RESISTANCE` / type-4 enchant |
| `ITEM_MOD_DAMAGE_PER_SECOND_SHORT` | Weapon DPS (float) | avg damage ÷ swing time |
@@ -4747,10 +4861,15 @@ what the item actually grants — not just its stored stat slots:
| `ITEM_MOD_CRIT_MELEE_RATING` + `ITEM_MOD_CRIT_RANGED_RATING` | Crit % | weapon-crit aura (52) |
| `ITEM_MOD_HIT_MELEE_RATING` + `ITEM_MOD_HIT_RANGED_RATING` | Hit % | hit aura (54) |
| `ITEM_MOD_HIT_SPELL_RATING` | Spell hit % | spell-hit aura (55) |
| `ITEM_MOD_CRIT_SPELL_RATING` | Spell crit % | spell-crit aura (57 / 71) |
| `ITEM_MOD_SPELL_DAMAGE_DONE_SHORT` | Spell damage | `MOD_DAMAGE_DONE` (13) |
| `ITEM_MOD_SPELL_HEALING_DONE_SHORT` | Healing | `MOD_HEALING_DONE` (135) |
| `ITEM_MOD_MANA_REGENERATION` | mp5 (mana) | `MOD_POWER_REGEN` (85) |
| `ITEM_MOD_DEFENSE_SKILL_RATING` | Defense skill | `MOD_SKILL` (30, misc 95) |
| `ITEM_MOD_DODGE_RATING` | Dodge % | `MOD_DODGE_PERCENT` (49) |
| `ITEM_MOD_PARRY_RATING` | Parry % | `MOD_PARRY_PERCENT` (47) |
| `ITEM_MOD_BLOCK_RATING` | Block chance % | `MOD_BLOCK_PERCENT` (51) |
| `ITEM_MOD_BLOCK_VALUE` | Shield block value | record (`m_block`) |
> **Values are vanilla-native, not ratings.** Vanilla has no rating
> system, so the percent-based stats (crit / hit / defense) report the raw
+152 -2
View File
@@ -87,6 +87,121 @@ enum Offsets {
FUN_REGISTER_FRAME_METHODS = 0x00701D80,
VAR_GAMETOOLTIP_METHOD_REGISTRY = 0x00C0CF20,
// CGameTooltip line pool (see TODO #97). The engine caps AddLine
// (FUN_00530270) at `numLinesAllocated`, which the XML-OnLoad scan
// (FUN_00529650, vtable slot 9) sets to the count of contiguous
// `<name>TextLeftN`/`TextRightN` FontStrings the template declares
// (30). Tooltip::LinePool grows it: it appends its own C++-created
// FontStrings to the three parallel arrays and bumps this count. Each
// array is a {count@+0x0, cap@+0x4, data@+0x8} descriptor; the
// reallocators realloc `data` to newCount*4 and copy `cap` old elements.
VAR_GAMETOOLTIP_VTABLE = 0x00808F60, // type guard for a resolved tooltip object
OFF_GAMETOOLTIP_NUM_LINES_ALLOC = 0x320, // int — pool size AddLine gates on
OFF_GAMETOOLTIP_TEXTLEFT_DESC = 0x324, // {count,cap,data}; data (+0x8) = CSimpleFontString*[]
OFF_GAMETOOLTIP_TEXTRIGHT_DESC = 0x330, // symmetric right column
OFF_GAMETOOLTIP_WRAPFLAG_DESC = 0x33C, // per-line wrap int[]
FUN_TOOLTIP_REALLOC_PTR_ARRAY = 0x00536C80, // __thiscall(desc, newCount) — pointer arrays
FUN_TOOLTIP_REALLOC_INT_ARRAY = 0x004368C0, // __thiscall(desc, newCount) — wrap-flag array
// GameTooltip:SetHyperlinkCompareItem (Tooltip::Compare) — builds the
// equipped item's tooltip natively (no Lua roundtrip) and appends
// colored stat deltas. FUN_GAMETOOLTIP_BUILD_ITEM is the engine's own
// item-tooltip builder that SetInventoryItem/SetHyperlink call:
// __thiscall(self, itemID, guid*, guid*, a4, a5, a6, headerFlag, a8, a9)
// Proven arg shape from FUN_005353b0/FUN_00535700 (all flags 0 → a clean
// item tooltip). The builder's own headerFlag (param_7) path merges the
// "Currently Equipped" text into the name line and force-wraps it, so we
// build clean (flag 0) and prepend the grey header ourselves by shifting
// the line FontStrings — see FUN_FONTSTRING_SET_* / OFF_FONTSTRING_*.
// FUN_GAMETOOLTIP_ADD_LINE is the raw add-line body (see LinePool notes);
// its color args are pointers to a 4-byte packed color {b,g,r,a} — the
// Lua AddLine handler (FUN_00531630) builds it as 0xFF<rr><gg><bb>, i.e.
// uint32 `0xFF000000 | r<<16 | g<<8 | b` in little-endian memory.
FUN_GAMETOOLTIP_BUILD_ITEM = 0x0052B650,
// OnTooltipSetItem support. FUN_GAMETOOLTIP_SCRIPT_RESOLVER is the
// CGGameTooltip vtable method (vtable 0x00808F60) that maps a script name
// to its handler slot; __thiscall(self, const char *name) -> int* slot,
// 0 if the name isn't a tooltip script. It first delegates to the base
// frame resolver, then checks OnTooltipSetDefaultAnchor(+0x444) /
// OnTooltipCleared(+0x44c) / OnTooltipAddMoney(+0x454) — each an 8-byte
// {handler, context} slot. Vanilla has no OnTooltipSetItem, and the object
// (alloc size 0x460) has no free 8-byte slot, so we co-hook this to hand
// out a C-side per-tooltip cell for that name. FUN_FRAME_INVOKE_SCRIPT is
// the engine's real script invoker __fastcall(handler /*ecx = slot[0]*/,
// frame /*edx*/): it binds the global `this` = frame and runs the handler
// under its own protected lua_pcall (0 args). We call it directly from the
// item-builder co-hook to fire OnTooltipSetItem.
// NOTE: the clear fires OnTooltipCleared via the wrapper FUN_00702690
// (self, &slot), which additionally stamps the global exec-context
// (DAT_00ceeac0) from slot[1]. That context mutation is only valid from
// the engine's top-level frame-script dispatch — firing it from deep in a
// nested Lua->C->Lua stack (e.g. an addon's SetAuctionItem wrapper)
// corrupts the outer script's context and faults. So we skip the wrapper
// and call the inner invoker, which is self-contained.
FUN_GAMETOOLTIP_SCRIPT_RESOLVER = 0x005295D0,
FUN_FRAME_INVOKE_SCRIPT = 0x00704D50,
FUN_GAMETOOLTIP_ADD_LINE = 0x00530270, // __thiscall(self, left, right, lColorBGRA*, rColorBGRA*, wrap)
OFF_GAMETOOLTIP_NUM_LINES = 0x31C, // int — live line count (AddLine index; +0x320 is the cap)
// The displayed item's identity is read via the existing
// OFF_TOOLTIP_ITEM_ID (+0x398) / OFF_TOOLTIP_ITEM_GUID_LO (+0x380) —
// link paths set the itemID, CGItem paths (SetInventoryItem/…) set only
// the GUID (see Tooltip::Compare::TooltipItemID and item/Tooltip.cpp).
// Line-shift primitives for prepending the grey "Currently Equipped"
// header. The per-line CSimpleFontString objects live in the parallel
// arrays at OFF_GAMETOOLTIP_TEXTLEFT/RIGHT_DESC (+8 = data). Each stores
// its text buffer pointer at +0xF0 and its 4-byte {b,g,r,a} color behind
// the pointer at +0xB8 (both verified from the setters below). We read a
// line's text/color and re-apply them one slot down, then set line 0.
FUN_FONTSTRING_SET_TEXT = 0x00771D80, // __thiscall(fs, text, flag=0)
FUN_FONTSTRING_SET_COLOR = 0x0077F750, // __thiscall(fs, colorBGRA*)
OFF_FONTSTRING_TEXT = 0xF0, // char* — current text buffer
OFF_FONTSTRING_COLOR_PTR = 0xB8, // ptr → 4-byte {b,g,r,a} color storage
// A line's FontString is only positioned once shown: set the desired
// flag at +0xC4 then call SHOW (FUN_0077fcb0) / HIDE (FUN_0077fc60) —
// the exact pair AddLine (FUN_00530270) and the clear use. Moving text
// into a previously-empty (hidden) cell without this leaves it
// unpositioned, so the line-shift must replicate it.
FUN_FONTSTRING_SHOW = 0x0077FCB0, // __fastcall(fs) — realizes +0xC4
FUN_FONTSTRING_HIDE = 0x0077FC60, // __fastcall(fs)
OFF_FONTSTRING_SHOWN_FLAG = 0xC4, // int — desired-shown flag
// The per-tooltip clear only HIDES line FontStrings (sets +0xC8 = 0)
// and clears the left text; right-column text buffers are left intact.
// So a shift must decide a cell's content by its actually-shown flag
// (+0xC8), not by whether +0xF0 still holds (stale) text.
OFF_FONTSTRING_VISIBLE = 0xC8, // int — actually-shown flag
// FontString creation primitives used by Tooltip::LinePool to build the
// extra lines entirely in C++ (no addon Lua). Calling conventions and
// offsets mirrored from Script_CreateFontString (0x00773C30),
// Script_SetFontObject (0x0079D1A0 → 0x00770C60), and Script_SetPoint
// (0x007A2540 → 0x00767C70) — see TODO #97's investigation update.
FUN_GAMETOOLTIP_SETUP_LINES = 0x00529650, // vtable slot 9 — the pool scan we co-hook
VAR_SIMPLEFONTSTRING_POOL = 0x00CF4D10, // CSimpleFontString free-list pool (allocator `this`)
VAR_SIMPLEFONTSTRING_CLASS_TAG = 0x00846544, // ".?AVCSimpleFontString@@" (alloc debug tag)
FUN_REGION_POOL_ALLOC = 0x00760450, // __thiscall(pool, zeroInit=0, tag, line=-2) -> raw mem
FUN_SIMPLEFONTSTRING_CTOR = 0x00770D30, // __thiscall(mem, parent, layer, sublayer) -> fs
FUN_REGION_SET_NAME = 0x0076C650, // __thiscall(region, name) — registers _G[name]
FUN_FONTSTRING_SET_FONT = 0x00770C60, // __thiscall(fs + OFF_FONTSTRING_FONT_HOLDER, fontObject)
FUN_REGION_SET_POINT = 0x00767C70, // __thiscall(region+OFF_REGION_ANCHOR, point, relAnchor, relPoint, x, y, flag=1)
OFF_FONTSTRING_FONT_HOLDER = 0xCC, // font-reference sub-object (SetFont `this`)
OFF_FONTSTRING_FONT_OBJECT = 0xD0, // font object pointer (holder + 4)
OFF_REGION_ANCHOR = 0x24, // LayoutFrame anchor sub-object (SetPoint `this` / relativeTo base)
DRAWLAYER_ARTWORK = 2,
FRAMEPOINT_TOPLEFT = 0,
FRAMEPOINT_LEFT = 3,
FRAMEPOINT_RIGHT = 5,
FRAMEPOINT_BOTTOMLEFT = 6,
// FUN_REGION_SET_POINT stores offsets in *internal* coordinates, not
// pixels. Script_SetPoint converts: `internal = pixel * [0x00832A44] /
// ([0x00832A4C] * 1024)` (FUN_0041ae60's factor × input ÷
// (FUN_0041ad70's return × DAT_007ffd68)). Both globals are runtime
// UI-scale floats; passing raw pixels makes offsets ~1000× too large.
VAR_UI_COORD_SCALE_MUL = 0x00832A44, // float numerator
VAR_UI_COORD_SCALE_DIV = 0x00832A4C, // float denominator base
UI_COORD_SCALE_UNIT = 1024, // DAT_007ffd68
// "Currently displayed thing" state fields on a GameTooltip frame
// instance. Each Set* path writes one of these (and zero or two
// others), and the per-tooltip Clear at FUN_00530050 zeroes all of
@@ -106,11 +221,32 @@ enum Offsets {
// (gameobject GUID) at 0x0052AA52 / 0x0052AA59.
// Only call site is the in-world hover handler
// FUN_00492890; no Lua `SetGameObject` method.
// - Clear (0x00530050) zeroes all of them.
OFF_TOOLTIP_ITEM_GUID_LO = 0x380, // 0 for SetItemByID (no CGItem)
// - Clear (FUN_GAMETOOLTIP_CLEAR) zeroes unit(+0x368)/GO(+0x370)/
// itemID(+0x398)/spell(+0x39c) — but NOT the item GUID +0x380/+0x384.
// That omission means the item GUID goes stale across a switch to a
// tooltip that shows no item (SetUnitAura, SetEquipmentSet, …), so
// GameTooltip:GetItem would resolve the *previous* item. We co-hook
// the clear (Tooltip::ClearItemGuid) to zero +0x380/+0x384 too, so
// every fresh tooltip resets it and only the item builder re-sets it.
// All 14 Set*/builder paths route through this clear, so the co-hook
// covers them uniformly. __fastcall(self).
FUN_GAMETOOLTIP_CLEAR = 0x00530050,
OFF_TOOLTIP_ITEM_GUID_LO = 0x380, // 0 for SetItemByID (no CGItem); stale-safe via the clear co-hook
OFF_TOOLTIP_ITEM_GUID_HI = 0x384,
OFF_TOOLTIP_ITEM_ID = 0x398,
OFF_TOOLTIP_SPELL_ID = 0x39C,
// Auction/compare descriptor. Script_GameTooltip_SetAuctionItem
// (0x00535810) has no CGItem instance, so it stashes the listing's
// random-property (suffix) id at tooltip+0x424 and marks the
// compare-descriptor valid by writing the builder's compare flag to
// this[0x110] (tooltip+0x440) = 1 (SetAuctionItem passes param_6=1;
// every other Set* path passes 0, and the builder writes it on each
// param_7==0 build — so +0x440 is a reliable per-build gate, not
// stale). This is 1.12's analog of 3.3.5's `tooltip[0x130]`-gated
// `piVar5[0x2d]` random-property read in GameTooltip:GetItem. GetItem
// reads +0x424 (only when +0x440 is set) to put the suffix in the link.
OFF_TOOLTIP_COMPARE_FLAG = 0x440,
OFF_TOOLTIP_COMPARE_SUFFIX = 0x424,
// Unit GUID written by the inner unit-tooltip builder
// (FUN_00529FE0) when `tooltip:SetUnit(token)` resolves the token
// to a non-zero GUID. Cleared by the same `FUN_00530050` clear
@@ -1045,6 +1181,13 @@ enum Offsets {
// descriptor bytes for a worn-and-bound item.
OFF_DESCRIPTOR_FLAGS = 0x3C,
ITEM_FLAG_SOULBOUND = 0x01,
// Within the descriptor, the item's random-property index
// (ITEM_FIELD_RANDOM_PROPERTIES_ID) is at +0x98 — this is the live
// instance's suffix, indexing ItemRandomProperties.dbc the same way
// StatAccum::ApplyRandomSuffix expects. Derived from the item-tooltip
// builder's `[descriptor + 0x26*4]` read (FUN_0052B650 @ ~0x52b7xx),
// which feeds it straight into `ItemRandomProperties[idx]`.
OFF_DESCRIPTOR_RANDOM_PROPERTY = 0x98,
// Bit 2 — instance is "unlocked", i.e. the lock referenced by
// `ItemStats.LockID` has been opened (rogue Pick Lock, key item,
// etc.). For unlockable items, the engine gates the
@@ -4111,6 +4254,13 @@ enum Offsets {
// stat auras. Parallel [3] array to APPLY_AURA_NAME / MISC_VALUE.
OFF_SPELL_RECORD_EFFECT_BASE_POINTS = 0x130, // int32[3]
// EffectBaseDice[3] — added to EffectBasePoints for the effect's fixed
// magnitude (mangos `CalculateSimpleValue = BasePoints + BaseDice`).
// For every item-stat aura BaseDice == 1, so value = BasePoints + 1;
// reading it directly matches the engine exactly. Verified at field 67
// (Effect@61, DieSides@64, BaseDice@67, BasePoints@76) against 7468.
OFF_SPELL_RECORD_EFFECT_BASE_DICE = 0x10C, // int32[3]
// Per-effect EffectRadiusIndex[3] → SpellRadius.dbc. Verified by
// FUN_006e6350, which reads spellRec[+0x160] (effect 0) and
// spellRec[+0x164] (effect 1) as radius indices. 0 = effect has no
+401
View File
@@ -0,0 +1,401 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Shared item-stat accumulation engine — see [src/item/StatAccum.h] for
// the public surface. This file holds the value derivation:
//
// Three sources feed the accumulator:
// 1. The base `ItemStats_C` record — the stored attributes
// (Str/Agi/Sta/Int/Spi, mana/health-on-equip), armor, six resists,
// and (for weapons) DPS derived from the damage/delay fields.
// 2. The item's on-equip spells (`m_spellTrigger == 1`) — vanilla
// (and Turtle's custom gear) store "special" bonuses (crit, hit,
// attack power, spell power/healing, mp5, defense, dodge, parry,
// block, spell crit, flat health/mana, …) as an equip spell whose
// aura effect carries the value, not as a stat slot. We decode those
// auras (the aura → key map is in AddSpellStatAuras).
// 3. The link's random suffix (`item:id:enchant:SUFFIX:unique`) —
// ItemRandomProperties.dbc → SpellItemEnchantment.dbc, whose stat
// suffixes are themselves equip spells (enchant type 3) and whose
// armor suffixes are direct resistance enchants (type 4).
//
// **Values are vanilla-native.** The percent-based stats (crit / hit /
// defense) are reported as the raw vanilla percentage under the modern
// rating key — e.g. Krol Blade's "+1% crit" is
// `ITEM_MOD_CRIT_MELEE_RATING = 1`, NOT the level-scaled rating (13) that
// TBC/Era would show. Vanilla has no rating conversion (it's
// level-dependent and doesn't exist in this client), so a native percent
// is the only honest, level-independent value.
//
// The aura → key map (below) covers the common gear stats. Rarer auras
// (percent attack power, weapon-skill-per-line, damage-taken, procs) are
// intentionally skipped — they either lack a clean single key or aren't
// part of the stat set.
#include "item/StatAccum.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstdlib>
#include <cstring>
namespace Item::StatAccum {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, bool requestIfMissing);
// Base-record m_statType uses the ItemModType enum: 0=mana, 1=health,
// 3=agility, 4=strength, 5=intellect, 6=spirit, 7=stamina (2 is
// deprecated). Returns nullptr for types with no vanilla key.
const char *StatKeyForItemModType(uint32_t statType) {
switch (statType) {
case 0: return "ITEM_MOD_MANA_SHORT";
case 1: return "ITEM_MOD_HEALTH_SHORT";
case 3: return "ITEM_MOD_AGILITY_SHORT";
case 4: return "ITEM_MOD_STRENGTH_SHORT";
case 5: return "ITEM_MOD_INTELLECT_SHORT";
case 6: return "ITEM_MOD_SPIRIT_SHORT";
case 7: return "ITEM_MOD_STAMINA_SHORT";
default: return nullptr;
}
}
// SPELL_AURA_MOD_STAT (29) misc uses the UnitStat enum instead: 0=Str,
// 1=Agi, 2=Sta, 3=Int, 4=Spi (-1 = all stats, not a single key).
// Verified against the "Increased <stat> 01" random-property spells.
const char *StatKeyForUnitStat(int stat) {
switch (stat) {
case 0: return "ITEM_MOD_STRENGTH_SHORT";
case 1: return "ITEM_MOD_AGILITY_SHORT";
case 2: return "ITEM_MOD_STAMINA_SHORT";
case 3: return "ITEM_MOD_INTELLECT_SHORT";
case 4: return "ITEM_MOD_SPIRIT_SHORT";
default: return nullptr;
}
}
// Resistance school index → key. 0 = armor (physical), 1..6 = the magic
// schools in Holy/Fire/Nature/Frost/Shadow/Arcane order.
const char *ResistKey(int school) {
static const char *const kKeys[7] = {
"RESISTANCE0_NAME", "RESISTANCE1_NAME", "RESISTANCE2_NAME",
"RESISTANCE3_NAME", "RESISTANCE4_NAME", "RESISTANCE5_NAME",
"RESISTANCE6_NAME",
};
return (school >= 0 && school <= 6) ? kKeys[school] : nullptr;
}
// Keys in this list are the ones AddByKey ever writes: the base stats +
// armor + six resistances, plus the equip-spell "special" stats. The
// equip-spell keys past the resistances are best-effort names verified
// against a 1.15.x client (crit melee/ranged are confirmed from a TBC
// GetItemStats dump); adjust here if the live client differs.
constexpr const char *kKeys[] = {
"ITEM_MOD_MANA_SHORT",
"ITEM_MOD_HEALTH_SHORT",
"ITEM_MOD_AGILITY_SHORT",
"ITEM_MOD_STRENGTH_SHORT",
"ITEM_MOD_INTELLECT_SHORT",
"ITEM_MOD_SPIRIT_SHORT",
"ITEM_MOD_STAMINA_SHORT",
"RESISTANCE0_NAME",
"RESISTANCE1_NAME",
"RESISTANCE2_NAME",
"RESISTANCE3_NAME",
"RESISTANCE4_NAME",
"RESISTANCE5_NAME",
"RESISTANCE6_NAME",
"ITEM_MOD_ATTACK_POWER_SHORT",
"ITEM_MOD_RANGED_ATTACK_POWER_SHORT",
"ITEM_MOD_CRIT_MELEE_RATING",
"ITEM_MOD_CRIT_RANGED_RATING",
"ITEM_MOD_HIT_MELEE_RATING",
"ITEM_MOD_HIT_RANGED_RATING",
"ITEM_MOD_HIT_SPELL_RATING",
"ITEM_MOD_CRIT_SPELL_RATING",
"ITEM_MOD_SPELL_DAMAGE_DONE_SHORT",
"ITEM_MOD_SPELL_HEALING_DONE_SHORT",
"ITEM_MOD_MANA_REGENERATION",
"ITEM_MOD_DEFENSE_SKILL_RATING",
"ITEM_MOD_DODGE_RATING",
"ITEM_MOD_PARRY_RATING",
"ITEM_MOD_BLOCK_RATING",
"ITEM_MOD_BLOCK_VALUE",
};
static_assert(sizeof(kKeys) / sizeof(kKeys[0]) == kCount,
"kCount must match the kKeys list");
long U32(const uint8_t *record, int offset) {
return static_cast<long>(*reinterpret_cast<const uint32_t *>(record + offset));
}
// Folds a spell's stat/resistance auras into `acc`. Used for both the
// item's own on-equip spells and random-suffix equip enchants. The value
// is the fixed-die `EffectBasePoints + EffectBaseDice` (mangos
// CalculateSimpleValue). Percent stats keep their vanilla percentage.
// Auras with no clean stat key are ignored (procs, dummies,
// weapon-skill-per-line, damage-taken, etc.).
void AddSpellStatAuras(Accum *acc, int spellID, long sign) {
if (spellID <= 0)
return;
const uint8_t *sp = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (sp == nullptr)
return;
auto aura = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
auto misc = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_MISC_VALUE);
auto base = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS);
auto dice = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_DICE);
// Vanilla "+N Attack Power" spells carry a melee (99) AND a ranged
// (124) aura with the same value; the generic melee key subsumes the
// ranged one, so we emit ranged only for ranged-only spells (scopes).
bool hasMeleeAP = false;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i)
if (aura[i] == 99)
hasMeleeAP = true;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const long v = static_cast<long>(base[i]) + static_cast<long>(dice[i]);
switch (aura[i]) {
case 29: // SPELL_AURA_MOD_STAT — misc = UnitStat index
AddByKey(acc, StatKeyForUnitStat(misc[i]), sign * v);
break;
case 34: // SPELL_AURA_MOD_INCREASE_HEALTH — flat +health
AddByKey(acc, "ITEM_MOD_HEALTH_SHORT", sign * v);
break;
case 35: // SPELL_AURA_MOD_INCREASE_ENERGY — misc = power type
if (misc[i] == 0) // mana
AddByKey(acc, "ITEM_MOD_MANA_SHORT", sign * v);
break;
case 22: // SPELL_AURA_MOD_RESISTANCE — misc = school bitmask
for (int school = 0; school <= 6; ++school)
if (misc[i] & (1 << school))
AddByKey(acc, ResistKey(school), sign * v);
break;
case 99: // SPELL_AURA_MOD_ATTACK_POWER
AddByKey(acc, "ITEM_MOD_ATTACK_POWER_SHORT", sign * v);
break;
case 124: // SPELL_AURA_MOD_RANGED_ATTACK_POWER
if (!hasMeleeAP)
AddByKey(acc, "ITEM_MOD_RANGED_ATTACK_POWER_SHORT", sign * v);
break;
case 52: // weapon crit % — TBC surfaces it as melee AND ranged
AddByKey(acc, "ITEM_MOD_CRIT_MELEE_RATING", sign * v);
AddByKey(acc, "ITEM_MOD_CRIT_RANGED_RATING", sign * v);
break;
case 54: // melee/ranged hit %
AddByKey(acc, "ITEM_MOD_HIT_MELEE_RATING", sign * v);
AddByKey(acc, "ITEM_MOD_HIT_RANGED_RATING", sign * v);
break;
case 55: // spell hit %
AddByKey(acc, "ITEM_MOD_HIT_SPELL_RATING", sign * v);
break;
case 57: // SPELL_AURA_MOD_SPELL_CRIT_CHANCE
case 71: // ..._SCHOOL (misc = school mask) — same stat
AddByKey(acc, "ITEM_MOD_CRIT_SPELL_RATING", sign * v);
break;
case 47: // SPELL_AURA_MOD_PARRY_PERCENT
AddByKey(acc, "ITEM_MOD_PARRY_RATING", sign * v);
break;
case 49: // SPELL_AURA_MOD_DODGE_PERCENT
AddByKey(acc, "ITEM_MOD_DODGE_RATING", sign * v);
break;
case 51: // SPELL_AURA_MOD_BLOCK_PERCENT (block chance)
AddByKey(acc, "ITEM_MOD_BLOCK_RATING", sign * v);
break;
case 13: // SPELL_AURA_MOD_DAMAGE_DONE — misc = school mask
if (misc[i] & 0x7E) // any magic school (bits 1..6)
AddByKey(acc, "ITEM_MOD_SPELL_DAMAGE_DONE_SHORT", sign * v);
break;
case 135: // SPELL_AURA_MOD_HEALING_DONE
AddByKey(acc, "ITEM_MOD_SPELL_HEALING_DONE_SHORT", sign * v);
break;
case 85: // SPELL_AURA_MOD_POWER_REGEN (mp5) — misc = power type
if (misc[i] == 0) // mana
AddByKey(acc, "ITEM_MOD_MANA_REGENERATION", sign * v);
break;
case 30: // SPELL_AURA_MOD_SKILL — misc = skill line (95 = Defense)
if (misc[i] == 95)
AddByKey(acc, "ITEM_MOD_DEFENSE_SKILL_RATING", sign * v);
break;
default:
break;
}
}
}
// Decodes the item's own on-equip spells (SpellTrigger == 1) into `acc`.
// On-use (0) and chance-on-hit (2) spells are skipped — they aren't
// passive stats. This is what surfaces crit / AP / spell power / hit /
// mp5 / defense on base items (vanilla stores them as equip spells, not
// stat slots).
void AddEquipSpellStats(Accum *acc, const uint8_t *record, long sign) {
auto ids = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
auto triggers = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; ++i)
if (ids[i] != 0 && triggers[i] == 1) // ON_EQUIP
AddSpellStatAuras(acc, static_cast<int>(ids[i]), sign);
}
} // namespace
void Init(Accum *acc) {
for (int i = 0; i < kCount; ++i) {
acc[i].key = kKeys[i];
acc[i].value = 0;
}
}
void AddByKey(Accum *acc, const char *key, long delta) {
if (key == nullptr || delta == 0)
return;
for (int i = 0; i < kCount; ++i)
if (std::strcmp(acc[i].key, key) == 0) {
acc[i].value += delta;
return;
}
}
const uint8_t *FetchRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
}
double ComputeDPS(const uint8_t *record) {
if (record == nullptr)
return 0.0;
const uint32_t delayMs = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_DELAY);
if (delayMs == 0)
return 0.0;
auto *dmgMin = reinterpret_cast<const float *>(
record + Offsets::OFF_ITEMSTATS_DAMAGE_MIN);
auto *dmgMax = reinterpret_cast<const float *>(
record + Offsets::OFF_ITEMSTATS_DAMAGE_MAX);
double avgTotal = 0.0;
for (int i = 0; i < Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT; ++i)
avgTotal += (static_cast<double>(dmgMin[i]) +
static_cast<double>(dmgMax[i])) * 0.5;
if (avgTotal <= 0.0)
return 0.0;
return avgTotal / (static_cast<double>(delayMs) / 1000.0);
}
void ApplyRandomSuffix(Accum *acc, int suffixID, long sign) {
if (suffixID <= 0)
return;
const uint8_t *rp = DBC::Record(Offsets::VAR_ITEMRANDOMPROP_RECORDS,
Offsets::VAR_ITEMRANDOMPROP_COUNT,
static_cast<uint32_t>(suffixID));
if (rp == nullptr)
return;
auto enchants = reinterpret_cast<const uint32_t *>(
rp + Offsets::OFF_ITEMRANDOMPROP_ENCHANT);
for (int s = 0; s < Offsets::ITEMRANDOMPROP_ENCHANT_SLOT_COUNT; ++s) {
const uint32_t enchantID = enchants[s];
if (enchantID == 0)
continue;
const uint8_t *en = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
if (en == nullptr)
continue;
auto type = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_TYPE);
auto amount = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
auto arg = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_ARG);
for (int i = 0; i < 3; ++i) { // SpellItemEnchantment has 3 effect slots
switch (type[i]) {
case 3: // equip spell — stat/resist value lives in the spell
AddSpellStatAuras(acc, arg[i], sign);
break;
case 4: // resistance — arg = school index (0 = armor)
AddByKey(acc, ResistKey(arg[i]), sign * static_cast<long>(amount[i]));
break;
case 5: // direct stat — arg = ItemModType, amount = value
AddByKey(acc, StatKeyForItemModType(static_cast<uint32_t>(arg[i])),
sign * static_cast<long>(amount[i]));
break;
default: // 1 proc / 2 weapon-damage / 6 totem / 7 use spell
break;
}
}
}
}
void AccumulateRecord(Accum *acc, const uint8_t *record, long sign) {
if (record == nullptr)
return;
auto *types = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_TYPE);
auto *values = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_VALUE);
for (int i = 0; i < Offsets::ITEMSTATS_STAT_SLOT_COUNT; ++i)
AddByKey(acc, StatKeyForItemModType(types[i]),
sign * static_cast<long>(values[i]));
AddByKey(acc, "RESISTANCE0_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_ARMOR));
AddByKey(acc, "RESISTANCE1_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_HOLY));
AddByKey(acc, "RESISTANCE2_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_FIRE));
AddByKey(acc, "RESISTANCE3_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_NATURE));
AddByKey(acc, "RESISTANCE4_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_FROST));
AddByKey(acc, "RESISTANCE5_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_SHADOW));
AddByKey(acc, "RESISTANCE6_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_ARCANE));
// Shield block value is a stored field, not a stat slot or aura.
AddByKey(acc, "ITEM_MOD_BLOCK_VALUE", sign * U32(record, Offsets::OFF_ITEMSTATS_BLOCK));
AddEquipSpellStats(acc, record, sign);
}
int ParseRandomSuffixFromLink(void *L, int idx) {
if (Game::Lua::Type(L, idx) != Game::Lua::TYPE_STRING)
return 0;
const char *s = Game::Lua::ToString(L, idx);
if (s == nullptr)
return 0;
const char *m = std::strstr(s, "item:");
if (m == nullptr)
return 0;
m += 5; // start of field 0 (itemID)
// Advance to the start of field 2 (past two ':' separators), stopping
// at the end of the hyperlink payload if the link is too short.
int colons = 0;
while (*m != '\0' && *m != '|') {
if (*m == ':') {
if (++colons == 2) {
++m;
break;
}
}
++m;
}
if (colons < 2)
return 0;
return std::atoi(m);
}
} // namespace Item::StatAccum
+77
View File
@@ -0,0 +1,77 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// Shared item-stat accumulation engine. Reads an item's stats (base
// record + on-equip spell auras + random-suffix enchants) into a
// fixed key→value table keyed by FrameXML global-string names
// (`ITEM_MOD_*`, `RESISTANCE*_NAME`). Two consumers:
// - `Item::Stats` — the `C_Item.GetItemStats` / `GetItemStatDelta`
// Lua bindings, which turn an `Accum` into a Lua table.
// - `Tooltip::Compare` — `GameTooltip:SetHyperlinkCompareItem`, which
// diffs a hovered item against the equipped item and renders the
// per-stat delta as colored tooltip lines.
//
// The full rationale for each stat source and the aura→key map lives in
// [src/item/StatAccum.cpp]; see also the "Item lookups" notes in
// CLAUDE.md for the `ItemStats_C` record shape.
namespace Item::StatAccum {
// One key→value pair. `key` is a FrameXML global-string name; `value`
// is the accumulated magnitude (native vanilla units — percents stay
// percents, not ratings).
struct Accum {
const char *key;
long value;
};
// Number of distinct keys an item can touch (base stats + armor + six
// resistances + the equip-spell "special" stats). Compile-time so
// callers can stack-allocate `Accum acc[kCount]`.
inline constexpr int kCount = 30;
// Fill `acc` (kCount entries) with the canonical key list and zero
// values. Call once before accumulating.
void Init(Accum *acc);
// Add `delta` to the entry matching `key` (no-op for an unknown key or
// a zero delta). Linear scan — the table is small and fixed.
void AddByKey(Accum *acc, const char *key, long delta);
// Passive cache peek for an item's `ItemStats_C` record. Does NOT warm
// the cache (returns nullptr for an uncached item) — see the item-cache
// race note in CLAUDE.md.
const uint8_t *FetchRecord(uint32_t itemID);
// Fold an item record's base stats + on-equip-spell stats into `acc`,
// scaled by `sign` (+1 to add, -1 to subtract for a delta). No-op for a
// null record.
void AccumulateRecord(Accum *acc, const uint8_t *record, long sign);
// Fold an item link's random-suffix bonus (ItemRandomProperties.dbc →
// SpellItemEnchantment.dbc) into `acc`, scaled by `sign`. No-op for
// suffixID <= 0.
void ApplyRandomSuffix(Accum *acc, int suffixID, long sign);
// Weapon DPS = average damage / swing time, or 0 for non-weapons.
double ComputeDPS(const uint8_t *record);
// Parse the random-property/suffix ID from the item link at 1-based Lua
// stack `idx` (`item:id:enchant:SUFFIX:unique`). 0 for a non-string
// arg, a bare `item:N`, or no `item:` payload.
int ParseRandomSuffixFromLink(void *L, int idx);
} // namespace Item::StatAccum
+25 -386
View File
@@ -17,383 +17,22 @@
// uses (so `_G[key]` yields the display label). `GetItemStatDelta` is the
// per-stat difference of two items.
//
// Three sources feed the table:
// 1. The base `ItemStats_C` record — the stored attributes
// (Str/Agi/Sta/Int/Spi, mana/health-on-equip), armor, six resists,
// and (for weapons) DPS derived from the damage/delay fields.
// 2. The item's on-equip spells (`m_spellTrigger == 1`) — vanilla
// stores "special" bonuses (crit, attack power, spell power, hit,
// mp5, defense, …) as an equip spell whose aura effect carries the
// value, not as a stat slot. We decode those auras.
// 3. The link's random suffix (`item:id:enchant:SUFFIX:unique`) —
// ItemRandomProperties.dbc → SpellItemEnchantment.dbc, whose stat
// suffixes are themselves equip spells (enchant type 3) and whose
// armor suffixes are direct resistance enchants (type 4).
//
// **Values are vanilla-native.** The percent-based stats (crit / hit /
// defense) are reported as the raw vanilla percentage under the modern
// rating key — e.g. Krol Blade's "+1% crit" is
// `ITEM_MOD_CRIT_MELEE_RATING = 1`, NOT the level-scaled rating (13) that
// TBC/Era would show. Vanilla has no rating conversion (it's
// level-dependent and doesn't exist in this client), so a native percent
// is the only honest, level-independent value.
//
// The aura → key map (below) covers the common gear stats. Rarer auras
// (percent attack power, weapon-skill-per-line, damage-taken, procs) are
// intentionally skipped — they either lack a clean single key or aren't
// part of the stat set.
// The stat derivation (base record + on-equip spell auras + random
// suffix) lives in the shared `Item::StatAccum` engine
// ([src/item/StatAccum.cpp]); this file is just the Lua bindings that
// turn an accumulator into a returned table.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "item/Arg.h"
#include "item/StatAccum.h"
#include <cstdint>
#include <cstdlib>
#include <cstring>
namespace Item::Stats {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, bool requestIfMissing);
// Passive peek — same contract as GetItemData / GetItemInfoInstant. Does
// not warm the cache (callback = nullptr, requestIfMissing = false), so
// an uncached item reads back as nullptr rather than orphaning an entry
// (see the item-cache race note in CLAUDE.md).
const uint8_t *FetchItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
}
// Base-record m_statType uses the ItemModType enum: 0=mana, 1=health,
// 3=agility, 4=strength, 5=intellect, 6=spirit, 7=stamina (2 is
// deprecated). Returns nullptr for types with no vanilla key.
const char *StatKeyForItemModType(uint32_t statType) {
switch (statType) {
case 0: return "ITEM_MOD_MANA_SHORT";
case 1: return "ITEM_MOD_HEALTH_SHORT";
case 3: return "ITEM_MOD_AGILITY_SHORT";
case 4: return "ITEM_MOD_STRENGTH_SHORT";
case 5: return "ITEM_MOD_INTELLECT_SHORT";
case 6: return "ITEM_MOD_SPIRIT_SHORT";
case 7: return "ITEM_MOD_STAMINA_SHORT";
default: return nullptr;
}
}
// SPELL_AURA_MOD_STAT (29) misc uses the UnitStat enum instead: 0=Str,
// 1=Agi, 2=Sta, 3=Int, 4=Spi (-1 = all stats, not a single key).
// Verified against the "Increased <stat> 01" random-property spells.
const char *StatKeyForUnitStat(int stat) {
switch (stat) {
case 0: return "ITEM_MOD_STRENGTH_SHORT";
case 1: return "ITEM_MOD_AGILITY_SHORT";
case 2: return "ITEM_MOD_STAMINA_SHORT";
case 3: return "ITEM_MOD_INTELLECT_SHORT";
case 4: return "ITEM_MOD_SPIRIT_SHORT";
default: return nullptr;
}
}
// Resistance school index → key. 0 = armor (physical), 1..6 = the magic
// schools in Holy/Fire/Nature/Frost/Shadow/Arcane order.
const char *ResistKey(int school) {
static const char *const kKeys[7] = {
"RESISTANCE0_NAME", "RESISTANCE1_NAME", "RESISTANCE2_NAME",
"RESISTANCE3_NAME", "RESISTANCE4_NAME", "RESISTANCE5_NAME",
"RESISTANCE6_NAME",
};
return (school >= 0 && school <= 6) ? kKeys[school] : nullptr;
}
// Accumulator over every key an item can touch: the base stats + armor +
// six resistances, plus the equip-spell "special" stats. Fixed and
// small — linear scan by key is fine.
struct Accum {
const char *key;
long value;
};
// Keys in this list are the ones AddByKey / Accumulate ever write. The
// equip-spell keys past the resistances are best-effort names to verify
// against a 1.15.x client (crit melee/ranged are confirmed from a TBC
// GetItemStats dump); adjust here if the live client differs.
constexpr const char *kKeys[] = {
"ITEM_MOD_MANA_SHORT",
"ITEM_MOD_HEALTH_SHORT",
"ITEM_MOD_AGILITY_SHORT",
"ITEM_MOD_STRENGTH_SHORT",
"ITEM_MOD_INTELLECT_SHORT",
"ITEM_MOD_SPIRIT_SHORT",
"ITEM_MOD_STAMINA_SHORT",
"RESISTANCE0_NAME",
"RESISTANCE1_NAME",
"RESISTANCE2_NAME",
"RESISTANCE3_NAME",
"RESISTANCE4_NAME",
"RESISTANCE5_NAME",
"RESISTANCE6_NAME",
"ITEM_MOD_ATTACK_POWER_SHORT",
"ITEM_MOD_RANGED_ATTACK_POWER_SHORT",
"ITEM_MOD_CRIT_MELEE_RATING",
"ITEM_MOD_CRIT_RANGED_RATING",
"ITEM_MOD_HIT_MELEE_RATING",
"ITEM_MOD_HIT_RANGED_RATING",
"ITEM_MOD_HIT_SPELL_RATING",
"ITEM_MOD_SPELL_DAMAGE_DONE_SHORT",
"ITEM_MOD_SPELL_HEALING_DONE_SHORT",
"ITEM_MOD_MANA_REGENERATION",
"ITEM_MOD_DEFENSE_SKILL_RATING",
};
constexpr int kAccumCount = static_cast<int>(sizeof(kKeys) / sizeof(kKeys[0]));
void InitAccum(Accum *acc) {
for (int i = 0; i < kAccumCount; ++i) {
acc[i].key = kKeys[i];
acc[i].value = 0;
}
}
void AddByKey(Accum *acc, const char *key, long delta) {
if (key == nullptr || delta == 0)
return;
for (int i = 0; i < kAccumCount; ++i)
if (std::strcmp(acc[i].key, key) == 0) {
acc[i].value += delta;
return;
}
}
long U32(const uint8_t *record, int offset) {
return static_cast<long>(*reinterpret_cast<const uint32_t *>(record + offset));
}
// Weapon DPS = average damage across all damage slots / swing time. The
// record stores min/max as floats per slot (vanilla weapons use slot 0,
// occasionally a second for a split physical/elemental line) and m_delay
// in milliseconds. Returns 0 for non-weapons (no delay or no damage),
// which the callers treat as "omit the key" — mirrors 3.3.5's range
// guard on `ITEM_MOD_DAMAGE_PER_SECOND_SHORT`.
double ComputeDPS(const uint8_t *record) {
if (record == nullptr)
return 0.0;
const uint32_t delayMs = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_DELAY);
if (delayMs == 0)
return 0.0;
auto *dmgMin = reinterpret_cast<const float *>(
record + Offsets::OFF_ITEMSTATS_DAMAGE_MIN);
auto *dmgMax = reinterpret_cast<const float *>(
record + Offsets::OFF_ITEMSTATS_DAMAGE_MAX);
double avgTotal = 0.0;
for (int i = 0; i < Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT; ++i)
avgTotal += (static_cast<double>(dmgMin[i]) +
static_cast<double>(dmgMax[i])) * 0.5;
if (avgTotal <= 0.0)
return 0.0;
return avgTotal / (static_cast<double>(delayMs) / 1000.0);
}
// Folds a spell's stat/resistance auras into `acc`. Used for both the
// item's own on-equip spells and random-suffix equip enchants. The value
// is the fixed-die `EffectBasePoints + 1` (all item-stat spells store a
// single fixed magnitude that way). Percent stats keep their vanilla
// percentage. Auras with no clean stat key are ignored (procs, dummies,
// weapon-skill-per-line, damage-taken, etc.).
void AddSpellStatAuras(Accum *acc, int spellID, long sign) {
if (spellID <= 0)
return;
const uint8_t *sp = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (sp == nullptr)
return;
auto aura = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
auto misc = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_MISC_VALUE);
auto base = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS);
// Vanilla "+N Attack Power" spells carry a melee (99) AND a ranged
// (124) aura with the same value; the generic melee key subsumes the
// ranged one, so we emit ranged only for ranged-only spells (scopes).
bool hasMeleeAP = false;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i)
if (aura[i] == 99)
hasMeleeAP = true;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const long v = static_cast<long>(base[i]) + 1;
switch (aura[i]) {
case 29: // SPELL_AURA_MOD_STAT — misc = UnitStat index
AddByKey(acc, StatKeyForUnitStat(misc[i]), sign * v);
break;
case 22: // SPELL_AURA_MOD_RESISTANCE — misc = school bitmask
for (int school = 0; school <= 6; ++school)
if (misc[i] & (1 << school))
AddByKey(acc, ResistKey(school), sign * v);
break;
case 99: // SPELL_AURA_MOD_ATTACK_POWER
AddByKey(acc, "ITEM_MOD_ATTACK_POWER_SHORT", sign * v);
break;
case 124: // SPELL_AURA_MOD_RANGED_ATTACK_POWER
if (!hasMeleeAP)
AddByKey(acc, "ITEM_MOD_RANGED_ATTACK_POWER_SHORT", sign * v);
break;
case 52: // weapon crit % — TBC surfaces it as melee AND ranged
AddByKey(acc, "ITEM_MOD_CRIT_MELEE_RATING", sign * v);
AddByKey(acc, "ITEM_MOD_CRIT_RANGED_RATING", sign * v);
break;
case 54: // melee/ranged hit %
AddByKey(acc, "ITEM_MOD_HIT_MELEE_RATING", sign * v);
AddByKey(acc, "ITEM_MOD_HIT_RANGED_RATING", sign * v);
break;
case 55: // spell hit %
AddByKey(acc, "ITEM_MOD_HIT_SPELL_RATING", sign * v);
break;
case 13: // SPELL_AURA_MOD_DAMAGE_DONE — misc = school mask
if (misc[i] & 0x7E) // any magic school (bits 1..6)
AddByKey(acc, "ITEM_MOD_SPELL_DAMAGE_DONE_SHORT", sign * v);
break;
case 135: // SPELL_AURA_MOD_HEALING_DONE
AddByKey(acc, "ITEM_MOD_SPELL_HEALING_DONE_SHORT", sign * v);
break;
case 85: // SPELL_AURA_MOD_POWER_REGEN (mp5) — misc = power type
if (misc[i] == 0) // mana
AddByKey(acc, "ITEM_MOD_MANA_REGENERATION", sign * v);
break;
case 30: // SPELL_AURA_MOD_SKILL — misc = skill line (95 = Defense)
if (misc[i] == 95)
AddByKey(acc, "ITEM_MOD_DEFENSE_SKILL_RATING", sign * v);
break;
default:
break;
}
}
}
// Decodes the item's own on-equip spells (SpellTrigger == 1) into `acc`.
// On-use (0) and chance-on-hit (2) spells are skipped — they aren't
// passive stats. This is what surfaces crit / AP / spell power / hit /
// mp5 / defense on base items (vanilla stores them as equip spells, not
// stat slots).
void AddEquipSpellStats(Accum *acc, const uint8_t *record, long sign) {
auto ids = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
auto triggers = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; ++i)
if (ids[i] != 0 && triggers[i] == 1) // ON_EQUIP
AddSpellStatAuras(acc, static_cast<int>(ids[i]), sign);
}
// Applies the item's random-suffix bonus (link field 2 → suffixID) to
// `acc`. Walks ItemRandomProperties.dbc → its SpellItemEnchantment IDs;
// type 3 enchants defer to the equip spell's auras, type 4 add a direct
// resistance, type 5 a direct stat. No-op for suffixID <= 0.
void ApplyRandomSuffix(Accum *acc, int suffixID, long sign) {
if (suffixID <= 0)
return;
const uint8_t *rp = DBC::Record(Offsets::VAR_ITEMRANDOMPROP_RECORDS,
Offsets::VAR_ITEMRANDOMPROP_COUNT,
static_cast<uint32_t>(suffixID));
if (rp == nullptr)
return;
auto enchants = reinterpret_cast<const uint32_t *>(
rp + Offsets::OFF_ITEMRANDOMPROP_ENCHANT);
for (int s = 0; s < Offsets::ITEMRANDOMPROP_ENCHANT_SLOT_COUNT; ++s) {
const uint32_t enchantID = enchants[s];
if (enchantID == 0)
continue;
const uint8_t *en = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
if (en == nullptr)
continue;
auto type = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_TYPE);
auto amount = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
auto arg = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_ARG);
for (int i = 0; i < 3; ++i) { // SpellItemEnchantment has 3 effect slots
switch (type[i]) {
case 3: // equip spell — stat/resist value lives in the spell
AddSpellStatAuras(acc, arg[i], sign);
break;
case 4: // resistance — arg = school index (0 = armor)
AddByKey(acc, ResistKey(arg[i]), sign * static_cast<long>(amount[i]));
break;
case 5: // direct stat — arg = ItemModType, amount = value
AddByKey(acc, StatKeyForItemModType(static_cast<uint32_t>(arg[i])),
sign * static_cast<long>(amount[i]));
break;
default: // 1 proc / 2 weapon-damage / 6 totem / 7 use spell
break;
}
}
}
}
// Adds item `record`'s base-record stats + on-equip-spell stats to `acc`
// scaled by `sign` (+1 to add, -1 to subtract — the delta path adds
// item2 then subtracts item1).
void Accumulate(Accum *acc, const uint8_t *record, long sign) {
if (record == nullptr)
return;
auto *types = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_TYPE);
auto *values = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_VALUE);
for (int i = 0; i < Offsets::ITEMSTATS_STAT_SLOT_COUNT; ++i)
AddByKey(acc, StatKeyForItemModType(types[i]),
sign * static_cast<long>(values[i]));
AddByKey(acc, "RESISTANCE0_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_ARMOR));
AddByKey(acc, "RESISTANCE1_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_HOLY));
AddByKey(acc, "RESISTANCE2_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_FIRE));
AddByKey(acc, "RESISTANCE3_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_NATURE));
AddByKey(acc, "RESISTANCE4_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_FROST));
AddByKey(acc, "RESISTANCE5_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_SHADOW));
AddByKey(acc, "RESISTANCE6_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_ARCANE));
AddEquipSpellStats(acc, record, sign);
}
// Parses the random-property/suffix ID from an item link's third field
// (`item:id:enchant:SUFFIX:unique`). Returns 0 for a non-string arg, a
// link with no `item:` payload, or a short link that omits the field
// (bare `item:N`, `item:N:E`). The engine's own link format is the
// four-field `item:%d:%d:%d:%d`, so the suffix is always field index 2.
int ParseRandomSuffix(void *L, int idx) {
if (Game::Lua::Type(L, idx) != Game::Lua::TYPE_STRING)
return 0;
const char *s = Game::Lua::ToString(L, idx);
if (s == nullptr)
return 0;
const char *m = std::strstr(s, "item:");
if (m == nullptr)
return 0;
m += 5; // start of field 0 (itemID)
// Advance to the start of field 2 (past two ':' separators), stopping
// at the end of the hyperlink payload if the link is too short.
int colons = 0;
while (*m != '\0' && *m != '|') {
if (*m == ':') {
if (++colons == 2) {
++m;
break;
}
}
++m;
}
if (colons < 2)
return 0;
return std::atoi(m);
}
using StatAccum::Accum;
// Pushes a fresh Lua table with each non-zero accumulator entry as
// `t[key] = value`, plus the weapon DPS under
@@ -405,7 +44,7 @@ int ParseRandomSuffix(void *L, int idx) {
// noise producing a spurious near-zero delta.
void PushAccumTable(void *L, const Accum *acc, double dps) {
Game::Lua::NewTable(L);
for (int i = 0; i < kAccumCount; ++i)
for (int i = 0; i < StatAccum::kCount; ++i)
if (acc[i].value != 0)
Game::Lua::SetFieldNumber(L, acc[i].key,
static_cast<double>(acc[i].value));
@@ -430,17 +69,17 @@ int __fastcall Script_GetItemStats(void *L) {
Game::Lua::Error(L, "Usage: C_Item.GetItemStats(itemLink)");
return 0;
}
const int suffixID = ParseRandomSuffix(L, 1);
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
const int suffixID = StatAccum::ParseRandomSuffixFromLink(L, 1);
const uint8_t *record = StatAccum::FetchRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
Accum acc[kAccumCount];
InitAccum(acc);
Accumulate(acc, record, +1);
ApplyRandomSuffix(acc, suffixID, +1);
PushAccumTable(L, acc, ComputeDPS(record));
Accum acc[StatAccum::kCount];
StatAccum::Init(acc);
StatAccum::AccumulateRecord(acc, record, +1);
StatAccum::ApplyRandomSuffix(acc, suffixID, +1);
PushAccumTable(L, acc, StatAccum::ComputeDPS(record));
return 1;
}
@@ -458,21 +97,21 @@ int __fastcall Script_GetItemStatDelta(void *L) {
L, "Usage: C_Item.GetItemStatDelta(itemLink1, itemLink2)");
return 0;
}
const int suffix1 = ParseRandomSuffix(L, 1);
const int suffix2 = ParseRandomSuffix(L, 2);
const uint8_t *r1 = FetchItemRecord(static_cast<uint32_t>(id1));
const uint8_t *r2 = FetchItemRecord(static_cast<uint32_t>(id2));
const int suffix1 = StatAccum::ParseRandomSuffixFromLink(L, 1);
const int suffix2 = StatAccum::ParseRandomSuffixFromLink(L, 2);
const uint8_t *r1 = StatAccum::FetchRecord(static_cast<uint32_t>(id1));
const uint8_t *r2 = StatAccum::FetchRecord(static_cast<uint32_t>(id2));
if (r1 == nullptr || r2 == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
Accum acc[kAccumCount];
InitAccum(acc);
Accumulate(acc, r2, +1);
ApplyRandomSuffix(acc, suffix2, +1);
Accumulate(acc, r1, -1);
ApplyRandomSuffix(acc, suffix1, -1);
PushAccumTable(L, acc, ComputeDPS(r2) - ComputeDPS(r1));
Accum acc[StatAccum::kCount];
StatAccum::Init(acc);
StatAccum::AccumulateRecord(acc, r2, +1);
StatAccum::ApplyRandomSuffix(acc, suffix2, +1);
StatAccum::AccumulateRecord(acc, r1, -1);
StatAccum::ApplyRandomSuffix(acc, suffix1, -1);
PushAccumTable(L, acc, StatAccum::ComputeDPS(r2) - StatAccum::ComputeDPS(r1));
return 1;
}
+31 -22
View File
@@ -18,6 +18,7 @@
#include "item/Link.h"
#include "item/Location.h"
#include "item/QualityColor.h"
#include "item/TooltipItem.h"
#include <cstdint>
#include <cstdio>
@@ -170,14 +171,15 @@ static void *ResolveItemByGuid(uint32_t guidLo, uint32_t guidHi) {
// expects addons to parse the itemID out of the link. Returning it
// directly saves callers a gsub round.
//
// BuildItemTooltip stores two fields per Set* call:
// - tooltip+0x398 → itemID (always populated for any item path)
// - tooltip+0x380/+0x384 → item GUID (populated only when there's a
// real CGItem — SetBagItem,
// SetInventoryItem, SetLootItem,
// SetMerchantItem, etc. Zero for
// SetItemByID / SetHyperlink
// which have no instance.)
// BuildItemTooltip stores the displayed item on the tooltip per Set*
// call — but only ONE of two fields depending on the path:
// - tooltip+0x398 → itemID (link paths with no instance: SetItemByID,
// SetHyperlink)
// - tooltip+0x380/+0x384 → item GUID (CGItem paths: SetBagItem,
// SetInventoryItem, SetLootItem,
// SetMerchantItem, …) — these leave the itemID
// field 0, so when +0x398 is 0 we recover the
// itemID from the GUID's live CGItem.
// Both are zeroed by the per-tooltip Clear at FUN_00530050.
//
// Two paths for the link:
@@ -203,14 +205,15 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
return 0;
auto *base = static_cast<const uint8_t *>(tooltipObj);
const int itemID = *reinterpret_cast<const int *>(base + Offsets::OFF_TOOLTIP_ITEM_ID);
// Resolve the currently-displayed item (link-path itemID, or the
// CGItem-path GUID when no unit/GO/spell is shown) — see
// Item::TooltipItem::CurrentID for the stale-GUID guarding.
uint64_t itemGuid = 0;
const int itemID = Item::TooltipItem::CurrentID(base, &itemGuid);
if (itemID <= 0)
return 0;
const uint32_t guidLo = *reinterpret_cast<const uint32_t *>(
base + Offsets::OFF_TOOLTIP_ITEM_GUID_LO);
const uint32_t guidHi = *reinterpret_cast<const uint32_t *>(
base + Offsets::OFF_TOOLTIP_ITEM_GUID_HI);
const uint32_t guidLo = static_cast<uint32_t>(itemGuid);
const uint32_t guidHi = static_cast<uint32_t>(itemGuid >> 32);
if (guidLo != 0 || guidHi != 0) {
if (void *cgItem = ResolveItemByGuid(guidLo, guidHi)) {
@@ -243,9 +246,13 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
// than nothing when we have an itemID.
}
// No-GUID path (SetItemByID, SetHyperlink for an item:N link with
// no per-instance data): build the basic colored link from cached
// itemID + quality + name.
// No-GUID path (SetItemByID, SetHyperlink, SetAuctionItem — items with
// no CGItem instance): build the colored link from cached itemID +
// quality + name. Auction/compare items have no GUID but do carry a
// random-property (suffix) id in the compare descriptor at
// OFF_TOOLTIP_COMPARE_SUFFIX — include it (in the link's suffix field)
// when the compare-descriptor flag is set, so the link round-trips the
// suffix for stat comparison. Matches 3.3.5's auction GetItem.
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
@@ -258,11 +265,15 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
int suffix = 0;
if (*reinterpret_cast<const int *>(base + Offsets::OFF_TOOLTIP_COMPARE_FLAG) != 0)
suffix = *reinterpret_cast<const int *>(base + Offsets::OFF_TOOLTIP_COMPARE_SUFFIX);
char link[256];
std::snprintf(link, sizeof(link),
"%s|Hitem:%d:0:0:0:0:0:0:0|h[%s]|h|r",
"%s|Hitem:%d:0:%d:0:0:0:0:0|h[%s]|h|r",
Item::QualityColor::Prefix(static_cast<int>(quality)),
itemID, name);
itemID, suffix, name);
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, link);
@@ -285,9 +296,7 @@ static int __fastcall Script_GameTooltipHasItem(void *L) {
Game::Lua::PushBool(L, 0);
return 1;
}
const int itemID = *reinterpret_cast<const int *>(
static_cast<const uint8_t *>(tooltipObj) + Offsets::OFF_TOOLTIP_ITEM_ID);
Game::Lua::PushBoolean(L, itemID > 0);
Game::Lua::PushBoolean(L, Item::TooltipItem::CurrentID(tooltipObj, nullptr) > 0);
return 1;
}
+65
View File
@@ -0,0 +1,65 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "item/TooltipItem.h"
#include "Offsets.h"
#include "item/ID.h"
#include "item/Location.h"
namespace Item::TooltipItem {
int CurrentID(const void *tooltipObj, uint64_t *outGuid) {
if (outGuid != nullptr)
*outGuid = 0;
if (tooltipObj == nullptr)
return 0;
auto *c = static_cast<const uint8_t *>(tooltipObj);
const uint64_t itemGuid =
*reinterpret_cast<const uint64_t *>(c + Offsets::OFF_TOOLTIP_ITEM_GUID_LO);
// Link item: the itemID field is authoritative (and reliably cleared).
const int linkItemID = *reinterpret_cast<const int *>(c + Offsets::OFF_TOOLTIP_ITEM_ID);
if (linkItemID > 0) {
if (outGuid != nullptr)
*outGuid = itemGuid;
return linkItemID;
}
// CGItem item: only the GUID is stored, and +0x380 isn't zeroed by the
// clear — so it lingers after the tooltip switches to a unit / GO /
// spell. Each of those sets its own (cleared-then-written) id, so trust
// the item GUID only when none of them is currently shown, then resolve
// it to the itemID (this also yields the dressed link for callers).
if (itemGuid == 0)
return 0;
if (*reinterpret_cast<const uint64_t *>(c + Offsets::OFF_TOOLTIP_UNIT_GUID_LO) != 0)
return 0;
if (*reinterpret_cast<const uint64_t *>(c + Offsets::OFF_TOOLTIP_GAMEOBJECT_GUID_LO) != 0)
return 0;
if (*reinterpret_cast<const int *>(c + Offsets::OFF_TOOLTIP_SPELL_ID) > 0)
return 0;
const uint8_t *cg = Item::Location::ResolveByGUID(itemGuid);
if (cg == nullptr)
return 0;
const int id = Item::ID::FromCGItem(cg);
if (id <= 0)
return 0;
if (outGuid != nullptr)
*outGuid = itemGuid;
return id;
}
} // namespace Item::TooltipItem
+41
View File
@@ -0,0 +1,41 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
namespace Item::TooltipItem {
// The itemID a tooltip is currently displaying, or 0 when it isn't
// showing an item. Two storage paths:
// - link paths (SetItemByID / SetHyperlink) set the itemID at
// OFF_TOOLTIP_ITEM_ID (+0x398), which the per-tooltip clear zeroes;
// - CGItem paths (SetInventoryItem / SetBagItem / SetItemByGUID) set the
// instance GUID at OFF_TOOLTIP_ITEM_GUID_LO (+0x380), which the clear
// does NOT zero — so it goes stale once the tooltip moves on to a unit
// / gameobject / spell.
// We trust the itemID directly, and otherwise resolve the GUID — but only
// when no unit / gameobject / spell id is currently set (each of those IS
// cleared-and-set on show, so their presence means the lingering item GUID
// isn't the live content). `outGuid` (optional) receives the item GUID (0
// when none), which lets callers build the dressed (suffixed) link.
// Returns 0 for a null tooltip or any non-item tooltip.
//
// Note: auction tooltips (SetAuctionItem) stash the itemID in a separate
// descriptor (+0x3D0) with no CGItem/GUID, so no dressed link is
// recoverable from them — we deliberately don't surface those here rather
// than return a suffix-stripped link.
int CurrentID(const void *tooltipObj, uint64_t *outGuid);
} // namespace Item::TooltipItem
+61
View File
@@ -0,0 +1,61 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Completes the per-tooltip clear so the item GUID doesn't go stale.
//
// The engine's tooltip clear FUN_GAMETOOLTIP_CLEAR (FUN_00530050) zeroes the
// unit (+0x368), gameobject (+0x370), itemID (+0x398) and spell (+0x39c)
// identity fields — but NOT the item GUID (+0x380/+0x384) that the item builder
// writes for CGItem tooltips (SetBagItem / SetInventoryItem / …). The engine
// never reads that GUID as "current item" (the builder always overwrites it
// before use), so leaving it stale is harmless to the engine — but
// GameTooltip:GetItem / HasItem / IsEquippedItem read it (via
// Item::TooltipItem::CurrentID's GUID fallback, which exists because CGItem
// tooltips leave the itemID field 0). So after the tooltip switches to content
// that sets no item — SetUnitAura (→ FUN_0052f880), SetEquipmentSet (→ SetText),
// etc. — the stale GUID made GetItem resolve the *previous* item.
//
// All 14 Set*/builder paths route through this clear (verified via its xrefs),
// and the item builder calls it *before* writing the GUID, so co-hooking the
// clear to also zero +0x380/+0x384 fixes every case uniformly: a fresh tooltip
// always resets the item GUID, and only a real item build re-sets it. The hook
// runs no Lua and touches nothing but those 8 bytes after the original clear —
// none of the fragility of the OnTooltipSetItem fire.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Tooltip::ClearItemGuid {
namespace {
using Clear_t = void(__fastcall *)(void *self);
Clear_t g_clearOriginal = nullptr;
void __fastcall Clear_h(void *self) {
g_clearOriginal(self);
if (self != nullptr)
*reinterpret_cast<uint64_t *>(static_cast<uint8_t *>(self) +
Offsets::OFF_TOOLTIP_ITEM_GUID_LO) = 0;
}
static const Game::HookAutoRegister _hook{
Offsets::FUN_GAMETOOLTIP_CLEAR,
reinterpret_cast<void *>(&Clear_h),
reinterpret_cast<void **>(&g_clearOriginal)};
} // namespace
} // namespace Tooltip::ClearItemGuid
+552
View File
@@ -0,0 +1,552 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `GameTooltip:SetHyperlinkCompareItem("itemLink" [, offset, shiftButton, comparisonTooltip])`
// — a backport of the 3.3.5+ tooltip method that powers item-comparison
// ("shopping") tooltips. The compared item comes from `itemLink` when
// given; otherwise it's taken from the item the `comparisonTooltip` (4th
// positional arg — the tooltip FrameXML's GameTooltip_ShowCompareItem
// passes) is displaying, so the method works purely tooltip-to-tooltip
// with no link, matching the retail call. Called on a tooltip frame (typically
// ShoppingTooltip1/2, but works on any GameTooltip-type frame), it:
//
// 1. Parses the hovered item link and finds which equipment slot(s)
// that item would occupy (via its inventoryType).
// 2. Picks the slot indicated by `offset` (1-based; two-slot items —
// rings, trinkets, one-hand weapons — expose slots at offset 1 and
// 2, so a caller iterates offset = 1 .. returnValue).
// 3. Fills the tooltip with the item currently EQUIPPED in that slot,
// led by a grey "Currently Equipped" header — done by calling the
// engine's own item-tooltip builder (FUN_GAMETOOLTIP_BUILD_ITEM) in
// its header mode. No Lua roundtrip.
// 4. Appends colored per-stat delta lines — yellow ITEM_DELTA_DESCRIPTION
// header, then green for a gain / red for a loss — showing how the
// hovered item compares to the equipped one. The delta is
// `hovered − equipped`, computed by the shared `Item::StatAccum`
// engine (the same one behind `C_Item.GetItemStatDelta`); the lines
// are added with the engine's raw add-line (FUN_GAMETOOLTIP_ADD_LINE).
//
// Returns the number of comparison slots for the hovered item (0 if it
// isn't equippable, isn't cached yet, the tooltip object can't be
// resolved, or nothing is equipped in the chosen slot) — a ClassicAPI
// extension over the retail signature, so a caller knows whether a second
// offset is worth querying.
//
// Everything is done with direct engine calls (the builder, clear, and
// add-line functions the engine uses itself) — no `lua_pcall` into
// registered Lua methods. The tooltip's final size/show is left to the
// caller (matching retail `SetHyperlinkCompareItem`, which populates but
// doesn't show). The 60-line pool from Tooltip::LinePool keeps the
// appended deltas under the AddLine cap.
//
// `shiftButton`: gates the stat-change breakdown. Deltas show by default
// (`true`, `1`, `nil`, or omitted) and are hidden only for an explicit
// `false` or `0` — just the grey header + the equipped item's tooltip.
// Both the boolean and the vanilla 1/nil convention are accepted —
// mirroring FrameXML's GameTooltip_ShowCompareItem.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/StatAccum.h"
#include "item/TooltipItem.h"
#include "tooltip/SetItemEvent.h"
#include "ui/ColorData.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace Tooltip::Compare {
namespace {
using Item::StatAccum::Accum;
// The engine's item-tooltip builder. __thiscall(self = tooltip object).
// With headerFlag != 0 it skips its leading clear and emits the grey
// "Currently Equipped" header; the item itself is looked up from itemID +
// guid regardless. See OFF/FUN notes in Offsets.h.
using BuildItemTooltip_t = void(__thiscall *)(void *self, uint32_t itemID, const void *guid,
const void *guid2, int a4, int a5, int a6,
int headerFlag, int a8, int a9);
// Raw add-line. Color args are pointers to a 4-byte packed {b,g,r,a}.
using AddLine_t = void(__thiscall *)(void *self, const char *left, const char *right,
const void *leftColor, const void *rightColor, int wrap);
using SetLineText_t = void(__thiscall *)(void *fontString, const char *text, int flag);
using SetLineColor_t = void(__thiscall *)(void *fontString, const void *colorBGRA);
// inventoryType (ItemStats_C m_inventoryType) → equipment slot ID(s).
// Slot IDs are the engine's canonical INVSLOT values (1 = Head … 19 =
// Tabard). Two-slot types (finger, trinket, one-hand weapon) fill both
// entries and return 2. Returns 0 for non-equippable types (bag, ammo,
// quiver, non-equip). `out` must hold 2 ints.
int SlotsForInvType(int invType, int *out) {
switch (invType) {
case 1: out[0] = 1; return 1; // HEAD
case 2: out[0] = 2; return 1; // NECK
case 3: out[0] = 3; return 1; // SHOULDER
case 4: out[0] = 4; return 1; // BODY (shirt)
case 5: case 20: out[0] = 5; return 1; // CHEST / ROBE
case 6: out[0] = 6; return 1; // WAIST
case 7: out[0] = 7; return 1; // LEGS
case 8: out[0] = 8; return 1; // FEET
case 9: out[0] = 9; return 1; // WRIST
case 10: out[0] = 10; return 1; // HANDS
case 11: out[0] = 11; out[1] = 12; return 2; // FINGER
case 12: out[0] = 13; out[1] = 14; return 2; // TRINKET
case 16: out[0] = 15; return 1; // CLOAK (back)
case 13: out[0] = 16; out[1] = 17; return 2; // ONE-HAND WEAPON (main/off)
case 17: out[0] = 16; return 1; // TWO-HAND WEAPON
case 21: out[0] = 16; return 1; // WEAPONMAINHAND
case 14: out[0] = 17; return 1; // SHIELD (off hand)
case 22: out[0] = 17; return 1; // WEAPONOFFHAND
case 23: out[0] = 17; return 1; // HOLDABLE (off hand)
case 15: out[0] = 18; return 1; // RANGED
case 25: out[0] = 18; return 1; // THROWN
case 26: out[0] = 18; return 1; // RANGEDRIGHT (wands)
case 28: out[0] = 18; return 1; // RELIC
case 19: out[0] = 19; return 1; // TABARD
default: return 0; // 0 non-equip, 18 bag, 24 ammo, 27 quiver
}
}
// English fallback labels, used when the client doesn't define the
// modern `_G[key]` global-string (vanilla 1.12 largely doesn't). Phrasing
// mirrors pfUI's eqcompare so the deltas read consistently on this
// client. Percent stats keep a "%" suffix since the values are native
// vanilla percentages.
struct KeyLabel {
const char *key;
const char *label;
};
constexpr KeyLabel kFallbackLabels[] = {
{"ITEM_MOD_MANA_SHORT", "Mana"},
{"ITEM_MOD_HEALTH_SHORT", "Health"},
{"ITEM_MOD_AGILITY_SHORT", "Agility"},
{"ITEM_MOD_STRENGTH_SHORT", "Strength"},
{"ITEM_MOD_INTELLECT_SHORT", "Intellect"},
{"ITEM_MOD_SPIRIT_SHORT", "Spirit"},
{"ITEM_MOD_STAMINA_SHORT", "Stamina"},
{"RESISTANCE0_NAME", "Armor"},
{"RESISTANCE1_NAME", "Holy Resistance"},
{"RESISTANCE2_NAME", "Fire Resistance"},
{"RESISTANCE3_NAME", "Nature Resistance"},
{"RESISTANCE4_NAME", "Frost Resistance"},
{"RESISTANCE5_NAME", "Shadow Resistance"},
{"RESISTANCE6_NAME", "Arcane Resistance"},
{"ITEM_MOD_ATTACK_POWER_SHORT", "Attack Power"},
{"ITEM_MOD_RANGED_ATTACK_POWER_SHORT", "Ranged Attack Power"},
{"ITEM_MOD_CRIT_MELEE_RATING", "Melee Crit %"},
{"ITEM_MOD_CRIT_RANGED_RATING", "Ranged Crit %"},
{"ITEM_MOD_HIT_MELEE_RATING", "Melee Hit %"},
{"ITEM_MOD_HIT_RANGED_RATING", "Ranged Hit %"},
{"ITEM_MOD_HIT_SPELL_RATING", "Spell Hit %"},
{"ITEM_MOD_CRIT_SPELL_RATING", "Spell Crit %"},
{"ITEM_MOD_SPELL_DAMAGE_DONE_SHORT", "Spell Damage"},
{"ITEM_MOD_SPELL_HEALING_DONE_SHORT", "Spell Healing"},
{"ITEM_MOD_MANA_REGENERATION", "Mana Regen"},
{"ITEM_MOD_DEFENSE_SKILL_RATING", "Defense"},
{"ITEM_MOD_DODGE_RATING", "Dodge %"},
{"ITEM_MOD_PARRY_RATING", "Parry %"},
{"ITEM_MOD_BLOCK_RATING", "Block %"},
{"ITEM_MOD_BLOCK_VALUE", "Block Value"},
{"ITEM_MOD_DAMAGE_PER_SECOND_SHORT", "Damage Per Second"},
{"ITEM_DELTA_DESCRIPTION",
"If you replace this item, the following stat changes will occur:"},
};
const char *FallbackLabel(const char *key) {
for (const auto &e : kFallbackLabels)
if (std::strcmp(e.key, key) == 0)
return e.label;
return key; // last resort — raw key beats crashing
}
// Resolve a display label for `key` into `out`. Prefers the client's
// `_G[key]` when it's a plain, non-empty string (respects localization /
// server-defined labels), read via a direct globals-table access — no
// pcall. Ignores format strings (a "%"-bearing global like "+%d Strength"
// would render garbage), falling back to the built-in English label.
void LabelFor(void *L, const char *key, char *out, int outSize) {
const int saved = Game::Lua::GetTop(L);
Game::Lua::PushString(L, key);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
const char *g = (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING)
? Game::Lua::ToString(L, -1)
: nullptr;
if (g != nullptr && *g != '\0' && std::strchr(g, '%') == nullptr)
std::snprintf(out, outSize, "%s", g);
else
std::snprintf(out, outSize, "%s", FallbackLabel(key));
Game::Lua::SetTop(L, saved);
}
// Delta colors — the canonical GlobalColor.dbc tags modern WoW uses for
// item comparison, pulled from UI::ColorData (its packed argb is already
// the {b,g,r,a} order the tooltip line-color setter wants).
constexpr uint32_t kGain = UI::ColorData::ByTag("INCREASE_STAT_COLOR"); // green
constexpr uint32_t kLoss = UI::ColorData::ByTag("DECREASE_STAT_COLOR"); // red
constexpr uint32_t kHeader = UI::ColorData::ByTag("NORMAL_FONT_COLOR"); // header yellow
// self:AddLine equivalent — left-aligned colored line via the engine's
// raw add-line. `color` must outlive the call (we pass its address).
void AddColoredLine(void *self, const char *text, const uint32_t &color) {
auto add = reinterpret_cast<AddLine_t>(Offsets::FUN_GAMETOOLTIP_ADD_LINE);
add(self, text, nullptr, &color, nullptr, 0);
}
// Appends the yellow ITEM_DELTA_DESCRIPTION header + one line per non-zero
// stat and a DPS line. No-op when nothing differs (identical items /
// self-compare add no delta section).
void RenderDeltas(void *self, void *L, const Accum *acc, double dpsDelta) {
const bool hasDps = (dpsDelta > 1e-4 || dpsDelta < -1e-4);
bool hasAny = hasDps;
for (int i = 0; !hasAny && i < Item::StatAccum::kCount; ++i)
hasAny = (acc[i].value != 0);
if (!hasAny)
return;
char label[192];
char line[256];
LabelFor(L, "ITEM_DELTA_DESCRIPTION", label, sizeof(label));
AddColoredLine(self, label, kHeader);
if (hasDps) {
LabelFor(L, "ITEM_MOD_DAMAGE_PER_SECOND_SHORT", label, sizeof(label));
std::snprintf(line, sizeof(line), "%s%.1f %s", dpsDelta > 0 ? "+" : "",
dpsDelta, label);
AddColoredLine(self, line, dpsDelta > 0 ? kGain : kLoss);
}
for (int i = 0; i < Item::StatAccum::kCount; ++i) {
const long v = acc[i].value;
if (v == 0)
continue;
LabelFor(L, acc[i].key, label, sizeof(label));
std::snprintf(line, sizeof(line), "%s%ld %s", v > 0 ? "+" : "", v, label);
AddColoredLine(self, line, v > 0 ? kGain : kLoss);
}
}
// --- "Currently Equipped" header ---------------------------------------
//
// The engine's builder can emit this header itself (FUN_0052B650 param_7),
// but that path merges it into the item's name line and force-wraps it.
// So we build the item cleanly and prepend the header the way vanilla
// addons (pfUI eqcompare) do — shift every built line's text+color down
// one slot and claim line 0 — but entirely in C via the engine's line
// FontStrings, no Lua method calls.
const char *FSText(const void *fs) {
if (fs == nullptr)
return nullptr;
return *reinterpret_cast<const char *const *>(
static_cast<const uint8_t *>(fs) + Offsets::OFF_FONTSTRING_TEXT);
}
// Whether a line's FontString is actually shown. The tooltip clear hides
// unused cells but keeps their (now stale) text, so this — not FSText —
// is what decides whether a cell carries live content to shift.
bool FSVisible(const void *fs) {
if (fs == nullptr)
return false;
return *reinterpret_cast<const int *>(
static_cast<const uint8_t *>(fs) + Offsets::OFF_FONTSTRING_VISIBLE) != 0;
}
// Reads a FontString's current color into `*out` (4-byte {b,g,r,a}).
// Returns false when the color storage isn't set, so the caller leaves
// the destination's existing color alone.
bool FSColor(const void *fs, uint32_t *out) {
if (fs == nullptr)
return false;
auto *slot = *reinterpret_cast<const uint32_t *const *>(
static_cast<const uint8_t *>(fs) + Offsets::OFF_FONTSTRING_COLOR_PTR);
if (slot == nullptr)
return false;
*out = *slot;
return true;
}
void FSSetText(void *fs, const char *text) {
if (fs != nullptr)
reinterpret_cast<SetLineText_t>(Offsets::FUN_FONTSTRING_SET_TEXT)(fs, text, 0);
}
void FSSetColor(void *fs, const uint32_t &color) {
if (fs != nullptr)
reinterpret_cast<SetLineColor_t>(Offsets::FUN_FONTSTRING_SET_COLOR)(fs, &color);
}
using ShowHideFS_t = void(__fastcall *)(void *fs);
// Realize a FontString's shown state the way the engine's AddLine/clear
// pair do: set the desired-shown flag, then call SHOW or HIDE so the
// FontString gets positioned (or dropped from) the layout. Without this a
// cell moved into a previously-hidden slot never anchors.
void FSSetShown(void *fs, bool shown) {
if (fs == nullptr)
return;
*reinterpret_cast<int *>(static_cast<uint8_t *>(fs) +
Offsets::OFF_FONTSTRING_SHOWN_FLAG) = shown ? 1 : 0;
reinterpret_cast<ShowHideFS_t>(
shown ? Offsets::FUN_FONTSTRING_SHOW : Offsets::FUN_FONTSTRING_HIDE)(fs);
}
// Copies `src`'s content (text + color) onto `dst`, replicating the shown
// state so `dst` positions correctly; hides `dst` when `src` isn't a live
// (shown) cell. Keyed on visibility, not text, so stale text the clear
// left behind in a hidden cell isn't resurrected. One column of the shift.
void CopyLineCell(void *dst, const void *src) {
if (dst == nullptr)
return;
if (FSVisible(src)) {
const char *text = FSText(src);
FSSetText(dst, text != nullptr ? text : "");
// Always set a color: source's if it has one, else white — so a
// stale color left on `dst` by a previous delta line is reset
// (lines the builder leaves at the default have no readable color).
uint32_t color = UI::ColorData::ByTag("WHITE_FONT_COLOR");
FSColor(src, &color);
FSSetColor(dst, color);
FSSetShown(dst, true);
} else {
FSSetText(dst, "");
FSSetShown(dst, false);
}
}
void PrependCurrentlyEquipped(void *self, void *L) {
auto *t = static_cast<uint8_t *>(self);
int *numLines = reinterpret_cast<int *>(t + Offsets::OFF_GAMETOOLTIP_NUM_LINES);
const int allocated =
*reinterpret_cast<const int *>(t + Offsets::OFF_GAMETOOLTIP_NUM_LINES_ALLOC);
const int n = *numLines;
if (n <= 0 || n + 1 > allocated)
return; // nothing built, or pool full (LinePool keeps 60, so rare)
auto **leftArr = *reinterpret_cast<void ***>(
t + Offsets::OFF_GAMETOOLTIP_TEXTLEFT_DESC + 8);
auto **rightArr = *reinterpret_cast<void ***>(
t + Offsets::OFF_GAMETOOLTIP_TEXTRIGHT_DESC + 8);
auto *wrapArr = *reinterpret_cast<int **>(
t + Offsets::OFF_GAMETOOLTIP_WRAPFLAG_DESC + 8);
if (leftArr == nullptr || rightArr == nullptr || wrapArr == nullptr)
return;
// Shift lines [0..n-1] down to [1..n]. High-to-low so each source is
// read before the next iteration overwrites it.
for (int i = n - 1; i >= 0; --i) {
CopyLineCell(leftArr[i + 1], leftArr[i]);
CopyLineCell(rightArr[i + 1], rightArr[i]);
wrapArr[i + 1] = wrapArr[i];
}
char header[128];
LabelFor(L, "CURRENTLY_EQUIPPED", header, sizeof(header)); // native 1.12 string
constexpr uint32_t grey = UI::ColorData::ByTag("GRAY_FONT_COLOR");
FSSetText(leftArr[0], header);
FSSetColor(leftArr[0], grey);
FSSetShown(leftArr[0], true);
FSSetText(rightArr[0], "");
FSSetShown(rightArr[0], false);
wrapArr[0] = 0;
*numLines = n + 1;
}
// Reads the live random-property (suffix) index off an equipped CGItem's
// descriptor — the same index ItemRandomProperties.dbc / ApplyRandomSuffix
// use. 0 for a plain item or a null descriptor.
int EquippedSuffix(const uint8_t *item) {
auto *desc = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
if (desc == nullptr)
return 0;
return static_cast<int>(*reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_DESCRIPTOR_RANDOM_PROPERTY));
}
uint64_t EquippedGUID(const uint8_t *item) {
auto *instance = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
if (instance == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
instance + Offsets::OFF_INSTANCE_BLOCK_GUID);
}
int __fastcall Script_SetHyperlinkCompareItem(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(
L, "Usage: GameTooltip:SetHyperlinkCompareItem(\"itemLink\" [, offset, shiftButton, comparisonTooltip])");
return 0;
}
const int argc = Game::Lua::GetTop(L);
auto bail = [&](int n) -> int {
Game::Lua::SetTop(L, argc);
Game::Lua::PushNumber(L, static_cast<double>(n));
return 1;
};
// Compared ("hovered") item: prefer the explicit link (arg 2). When
// it's absent, fall back to whatever the comparisonTooltip (arg 5) is
// displaying — the builder stashes that tooltip's itemID/GUID, so we
// recover the itemID directly and the suffix via the GUID's live
// CGItem. This makes the method 1:1 with the retail call, which can be
// driven purely tooltip-to-tooltip.
int hoveredID = Item::Arg::ResolveItemID(L, 2);
int hoveredSuffix = Item::StatAccum::ParseRandomSuffixFromLink(L, 2);
if (hoveredID <= 0 && Game::Lua::Type(L, 5) == Game::Lua::TYPE_TABLE) {
if (void *cmp = Game::Lua::ResolveObject(L, 5)) {
uint64_t g = 0;
hoveredID = Item::TooltipItem::CurrentID(cmp, &g);
if (g != 0) {
const uint8_t *cg = Item::Location::ResolveByGUID(g);
if (cg != nullptr)
hoveredSuffix = EquippedSuffix(cg);
}
}
}
const uint8_t *hoveredRec =
hoveredID > 0 ? Item::StatAccum::FetchRecord(static_cast<uint32_t>(hoveredID)) : nullptr;
if (hoveredRec == nullptr)
return bail(0); // no link and no comparisonTooltip item, or not cached yet
const int invType = static_cast<int>(*reinterpret_cast<const uint32_t *>(
hoveredRec + Offsets::OFF_ITEMSTATS_INVENTORY_TYPE));
int slots[2] = {0, 0};
const int nSlots = SlotsForInvType(invType, slots);
if (nSlots == 0)
return bail(0); // non-equippable
int offset = (Game::Lua::Type(L, 3) == Game::Lua::TYPE_NUMBER)
? static_cast<int>(Game::Lua::ToNumber(L, 3))
: 1;
if (offset < 1)
offset = 1;
if (offset > nSlots)
return bail(nSlots);
const int slotID = slots[offset - 1];
// shiftButton: gates the stat-change breakdown. Deltas show by default
// — for `true`, `1`, `nil`, or omitted — and are hidden ONLY for an
// explicit `false` or `0`. Accepting both the boolean and the vanilla
// 1/nil convention matches how callers (and FrameXML's
// GameTooltip_ShowCompareItem) forward the modified-click state.
bool showDeltas = true;
const int shiftType = Game::Lua::Type(L, 4);
if (shiftType == Game::Lua::TYPE_BOOLEAN)
showDeltas = Game::Lua::ToBoolean(L, 4) != 0;
else if (shiftType == Game::Lua::TYPE_NUMBER)
showDeltas = Game::Lua::ToNumber(L, 4) != 0.0;
// What's equipped there? Nothing → no comparison; leave the tooltip
// as the caller had it.
const uint8_t *eqItem = Item::Location::ResolveEquipmentSlot(slotID);
if (eqItem == nullptr)
return bail(nSlots);
const int equippedID = Item::ID::FromCGItem(eqItem);
if (equippedID <= 0)
return bail(nSlots);
const int equippedSuffix = EquippedSuffix(eqItem);
uint64_t equippedGUID = EquippedGUID(eqItem);
// The tooltip's underlying CFrameScriptObject — the `this` for the
// engine's builder / add-line thiscalls.
void *self = Game::Lua::ResolveObject(L, 1);
if (self == nullptr)
return bail(0);
// Build the equipped item into the tooltip via the engine's own
// item-tooltip builder (the call SetInventoryItem/SetHyperlink use),
// proven arg shape from FUN_005353b0/FUN_00535700 — all flags 0. This
// clears + fills the item cleanly (no wrap). The grey "Currently
// Equipped" header is prepended afterwards. Suppress OnTooltipSetItem for
// this internal build so a handler doesn't run mid-way through the
// line-shift below (it fires for the *hovered* item's real Set* instead).
{
Tooltip::SetItemEvent::Suppressor noEvent;
reinterpret_cast<BuildItemTooltip_t>(Offsets::FUN_GAMETOOLTIP_BUILD_ITEM)(
self, static_cast<uint32_t>(equippedID), &equippedGUID, &equippedGUID, 0, 0, 0, 0, 0, 0);
}
PrependCurrentlyEquipped(self, L);
// Delta = hovered − equipped, then render (unless suppressed).
if (showDeltas) {
Accum acc[Item::StatAccum::kCount];
Item::StatAccum::Init(acc);
Item::StatAccum::AccumulateRecord(acc, hoveredRec, +1);
Item::StatAccum::ApplyRandomSuffix(acc, hoveredSuffix, +1);
const uint8_t *equippedRec =
Item::StatAccum::FetchRecord(static_cast<uint32_t>(equippedID));
if (equippedRec != nullptr) {
Item::StatAccum::AccumulateRecord(acc, equippedRec, -1);
Item::StatAccum::ApplyRandomSuffix(acc, equippedSuffix, -1);
}
const double dpsDelta = Item::StatAccum::ComputeDPS(hoveredRec) -
(equippedRec ? Item::StatAccum::ComputeDPS(equippedRec) : 0.0);
RenderDeltas(self, L, acc, dpsDelta);
}
return bail(nSlots);
}
// `GameTooltip:IsEquippedItem()` → bool. True when the item the tooltip is
// currently displaying is equipped in a character-pane slot. The displayed
// item is resolved by Item::TooltipItem::CurrentID (which guards the stale
// item-GUID so unit / spell / gameobject tooltips read 0 and never
// false-positive). Backports the 3.3.5 GameTooltip method (registry
// index 63); the equipment walk mirrors C_Item.IsEquippedItem's itemID
// path.
int __fastcall Script_IsEquippedItem(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: GameTooltip:IsEquippedItem()");
return 0;
}
void *self = Game::Lua::ResolveObject(L, 1);
const int itemID = self != nullptr ? Item::TooltipItem::CurrentID(self, nullptr) : 0;
bool equipped = false;
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST;
itemID > 0 && slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
const uint8_t *it = Item::Location::ResolveEquipmentSlot(slot);
if (it != nullptr && Item::ID::FromCGItem(it) == itemID) {
equipped = true;
break;
}
}
Game::Lua::PushBool(L, equipped);
return 1;
}
const Game::Lua::FrameMethodEntry g_methods[] = {
{"SetHyperlinkCompareItem", &Script_SetHyperlinkCompareItem},
{"IsEquippedItem", &Script_IsEquippedItem},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_GAMETOOLTIP_METHOD_REGISTRY),
g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Tooltip::Compare
+222
View File
@@ -0,0 +1,222 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Lift GameTooltip's 30-line cap (TODO #97) — pure C++, no addon Lua.
//
// Vanilla `GameTooltip:AddLine` (FUN_00530270) silently drops any line
// past `numLinesAllocated`, and that count is just "how many contiguous
// `<name>TextLeftN`/`TextRightN` FontStrings the FrameXML template
// declared" — established once by the tooltip's XML-OnLoad region scan
// (FUN_00529650, vtable slot 9). Stat-heavy tooltips (pfUI's eqcompare
// block) lose their tail past line 30.
//
// We grow the pool the same way 3.3.5's AddLine grows on demand, but at
// scan time instead of per line: co-hook the scan, and after the engine
// has set up the template's 30 lines, create `TextLeft/Right 31..60` in
// C++ (mirroring Script_CreateFontString + SetFontObject + SetPoint) and
// append them to the tooltip's three line arrays. This runs once per
// GameTooltip-family frame at creation (and per `/reload`), so it
// auto-covers GameTooltip, ShoppingTooltip1/2, ItemRefTooltip,
// WorldMapTooltip, … with no frame list and no `AddLine` hook.
//
// Everything the grow needs — parent frame, the previous line (for its
// anchor target + font), and the array data pointers — comes straight
// from the just-populated tooltip, so there's no `_G` name lookup. The
// new lines are still *named* (`FUN_REGION_SET_NAME`) so addons that read
// `_G[name.."TextLeft"..i]` keep working and a re-run of the scan
// recounts them idempotently. See TODO #97 for the full RE trail.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstdio>
namespace Tooltip::LinePool {
namespace {
// Grow every GameTooltip-family frame to this many lines. pfUI's worst
// case is ~32; 60 leaves headroom without an unbounded pool.
constexpr uint32_t kMaxLines = 60;
// --- engine entry points (all __thiscall; see Offsets.h) -------------------
using GetName_t = const char *(__thiscall *)(void *self);
using PoolAlloc_t = void *(__thiscall *)(void *pool, int zeroInit, const char *tag, int line);
using FontStringCtor_t = void *(__thiscall *)(void *mem, void *parent, int layer, int sublayer);
using SetName_t = void(__thiscall *)(void *region, const char *name);
using SetFont_t = void(__thiscall *)(void *fontHolder, void *fontObject);
using SetPoint_t = void(__thiscall *)(void *anchor, int point, void *relAnchor, int relPoint,
float x, float y, int flag);
using ReallocArray_t = void(__thiscall *)(void *descriptor, uint32_t newCount);
// The scan we co-hook: __thiscall(tooltip, node, flag). Hooked via the
// standard __fastcall(ecx, edx, …) shim — __thiscall passes `this` in ECX
// and the rest on the stack, so the trailing stack args line up and the
// phantom `edx` is harmless.
using Scan_t = void(__fastcall *)(void *tooltip, void *edx, void *node, int flag);
Scan_t g_scanOriginal = nullptr;
uint8_t *DescData(uint8_t *tooltip, int descOffset) {
return *reinterpret_cast<uint8_t **>(tooltip + descOffset + 8);
}
// FUN_REGION_SET_POINT stores offsets in internal coordinates, so a pixel
// offset must be scaled the way Script_SetPoint does before being passed —
// otherwise a -2px gap lands ~1000px off. Returns 0 if the scale globals
// aren't populated yet (degrades to a 0-offset anchor, not a wild one).
float PixelToInternal(float pixels) {
const float mul = *reinterpret_cast<const float *>(Offsets::VAR_UI_COORD_SCALE_MUL);
const float divBase = *reinterpret_cast<const float *>(Offsets::VAR_UI_COORD_SCALE_DIV);
const float denom = divBase * static_cast<float>(Offsets::UI_COORD_SCALE_UNIT);
if (denom == 0.0f)
return 0.0f;
return pixels * mul / denom;
}
// Reads a FontString's current font object (holder+4). Used to copy the
// previous line's font onto a new one so it renders identically.
void *FontObjectOf(void *fontString) {
if (fontString == nullptr)
return nullptr;
return *reinterpret_cast<void **>(reinterpret_cast<uint8_t *>(fontString) +
Offsets::OFF_FONTSTRING_FONT_OBJECT);
}
// Builds one CSimpleFontString: alloc → ctor(parent) → name → font (copied
// from `fontSource`) → anchor. Mirrors Script_CreateFontString's core plus
// the SetFontObject / SetPoint the Lua caller would do. Returns nullptr on
// allocation failure.
void *CreateLine(void *parent, const char *name, void *fontSource, int point,
void *relRegion, int relPoint, float x, float y) {
auto alloc = reinterpret_cast<PoolAlloc_t>(Offsets::FUN_REGION_POOL_ALLOC);
void *mem = alloc(reinterpret_cast<void *>(Offsets::VAR_SIMPLEFONTSTRING_POOL), 0,
reinterpret_cast<const char *>(Offsets::VAR_SIMPLEFONTSTRING_CLASS_TAG), -2);
if (mem == nullptr)
return nullptr;
auto ctor = reinterpret_cast<FontStringCtor_t>(Offsets::FUN_SIMPLEFONTSTRING_CTOR);
void *fs = ctor(mem, parent, Offsets::DRAWLAYER_ARTWORK, 1);
if (fs == nullptr)
return nullptr;
auto *fsBytes = reinterpret_cast<uint8_t *>(fs);
auto setName = reinterpret_cast<SetName_t>(Offsets::FUN_REGION_SET_NAME);
setName(fs, name);
void *fontObject = FontObjectOf(fontSource);
if (fontObject != nullptr) {
auto setFont = reinterpret_cast<SetFont_t>(Offsets::FUN_FONTSTRING_SET_FONT);
setFont(fsBytes + Offsets::OFF_FONTSTRING_FONT_HOLDER, fontObject);
}
if (relRegion != nullptr) {
auto setPoint = reinterpret_cast<SetPoint_t>(Offsets::FUN_REGION_SET_POINT);
setPoint(fsBytes + Offsets::OFF_REGION_ANCHOR, point,
reinterpret_cast<uint8_t *>(relRegion) + Offsets::OFF_REGION_ANCHOR,
relPoint, PixelToInternal(x), PixelToInternal(y), 1);
}
return fs;
}
// Appends one (left, right) line pair to the tooltip's three parallel
// arrays and bumps `numLinesAllocated` — the C++ equivalent of the
// engine's own FUN_0061fe30. Reallocs each array via its {count,cap,data}
// descriptor (the reallocator preserves `cap` old elements; we set the new
// cap to match, as the scan does).
void AppendLine(uint8_t *tooltip, void *left, void *right) {
auto reallocPtr = reinterpret_cast<ReallocArray_t>(Offsets::FUN_TOOLTIP_REALLOC_PTR_ARRAY);
auto reallocInt = reinterpret_cast<ReallocArray_t>(Offsets::FUN_TOOLTIP_REALLOC_INT_ARRAY);
const uint32_t index = *reinterpret_cast<uint32_t *>(
tooltip + Offsets::OFF_GAMETOOLTIP_NUM_LINES_ALLOC);
const uint32_t newCount = index + 1;
reallocPtr(tooltip + Offsets::OFF_GAMETOOLTIP_TEXTLEFT_DESC, newCount);
*reinterpret_cast<uint32_t *>(tooltip + Offsets::OFF_GAMETOOLTIP_TEXTLEFT_DESC + 4) = newCount;
reallocPtr(tooltip + Offsets::OFF_GAMETOOLTIP_TEXTRIGHT_DESC, newCount);
*reinterpret_cast<uint32_t *>(tooltip + Offsets::OFF_GAMETOOLTIP_TEXTRIGHT_DESC + 4) = newCount;
reallocInt(tooltip + Offsets::OFF_GAMETOOLTIP_WRAPFLAG_DESC, newCount);
*reinterpret_cast<uint32_t *>(tooltip + Offsets::OFF_GAMETOOLTIP_WRAPFLAG_DESC + 4) = newCount;
reinterpret_cast<void **>(DescData(tooltip, Offsets::OFF_GAMETOOLTIP_TEXTLEFT_DESC))[index] = left;
reinterpret_cast<void **>(DescData(tooltip, Offsets::OFF_GAMETOOLTIP_TEXTRIGHT_DESC))[index] = right;
reinterpret_cast<uint32_t *>(DescData(tooltip, Offsets::OFF_GAMETOOLTIP_WRAPFLAG_DESC))[index] = 0;
*reinterpret_cast<uint32_t *>(tooltip + Offsets::OFF_GAMETOOLTIP_NUM_LINES_ALLOC) = newCount;
}
// Grows `tooltip`'s line pool up to kMaxLines, creating + appending the
// missing `TextLeftN`/`TextRightN` pairs. No-op if the pool is empty
// (scan didn't populate) or already at/above the target.
void GrowPool(uint8_t *tooltip) {
uint32_t count = *reinterpret_cast<uint32_t *>(
tooltip + Offsets::OFF_GAMETOOLTIP_NUM_LINES_ALLOC);
if (count == 0 || count >= kMaxLines)
return;
auto vtable = *reinterpret_cast<void ***>(tooltip);
auto getName = reinterpret_cast<GetName_t>(vtable[1]); // slot 1 = GetName
const char *baseName = getName(tooltip);
if (baseName == nullptr || baseName[0] == '\0')
return;
for (uint32_t index = count; index < kMaxLines; ++index) {
// Previous line supplies both the anchor target and the font.
void *prevLeft = reinterpret_cast<void **>(
DescData(tooltip, Offsets::OFF_GAMETOOLTIP_TEXTLEFT_DESC))[index - 1];
void *prevRight = reinterpret_cast<void **>(
DescData(tooltip, Offsets::OFF_GAMETOOLTIP_TEXTRIGHT_DESC))[index - 1];
if (prevLeft == nullptr || prevRight == nullptr)
break;
char nameLeft[96];
char nameRight[96];
std::snprintf(nameLeft, sizeof nameLeft, "%sTextLeft%u", baseName, index + 1);
std::snprintf(nameRight, sizeof nameRight, "%sTextRight%u", baseName, index + 1);
// Left: TOPLEFT anchored to the previous left line's BOTTOMLEFT at
// (0, -2) — the template's exact chain. Right: RIGHT anchored to
// this line's own left LEFT at (40, 0).
void *left = CreateLine(tooltip, nameLeft, prevLeft, Offsets::FRAMEPOINT_TOPLEFT,
prevLeft, Offsets::FRAMEPOINT_BOTTOMLEFT, 0.0f, -2.0f);
if (left == nullptr)
break;
void *right = CreateLine(tooltip, nameRight, prevRight, Offsets::FRAMEPOINT_RIGHT,
left, Offsets::FRAMEPOINT_LEFT, 40.0f, 0.0f);
if (right == nullptr)
break;
AppendLine(tooltip, left, right);
}
}
// Co-hook of the tooltip's XML-OnLoad region scan. Runs the original
// (which counts the template's 30 lines and sizes the arrays), then grows
// the pool. Guarded on the CGameTooltip vtable so we never touch a
// non-tooltip object.
void __fastcall Scan_h(void *tooltip, void *edx, void *node, int flag) {
g_scanOriginal(tooltip, edx, node, flag);
if (tooltip != nullptr &&
*reinterpret_cast<uintptr_t *>(tooltip) == Offsets::VAR_GAMETOOLTIP_VTABLE)
GrowPool(reinterpret_cast<uint8_t *>(tooltip));
}
static const Game::HookAutoRegister _hookreg{
Offsets::FUN_GAMETOOLTIP_SETUP_LINES,
reinterpret_cast<void *>(&Scan_h),
reinterpret_cast<void **>(&g_scanOriginal)};
} // namespace
} // namespace Tooltip::LinePool
+168
View File
@@ -0,0 +1,168 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `GameTooltip:HookScript("OnTooltipSetItem", fn)` — backport of the modern
// tooltip script that fires whenever a tooltip's item is set, so addons can
// annotate item tooltips by hooking one script instead of wrapping every
// Set* method (the pattern AtlasLoot resorts to in vanilla, rebuilding the
// tooltip and clobbering its item linkage). Fully native — a real frame
// script, settable via the standard SetScript / GetScript / HookScript.
//
// 1.12 already has the machinery for tooltip scripts, just not this one:
//
// - The CGGameTooltip vtable resolver FUN_GAMETOOLTIP_SCRIPT_RESOLVER maps a
// script name to its handler slot on the tooltip (OnTooltipCleared etc.,
// each an 8-byte {handler, context} pair). SetScript/GetScript/HookScript
// all go through it. We co-hook it: for "OnTooltipSetItem" (a name the
// engine doesn't know) we hand back a pointer to a C-side per-tooltip cell
// — the tooltip object (alloc size 0x460) has no free 8-byte slot, and the
// resolver's only contract is "return the address the script lives at", so
// external storage works exactly like an in-object slot.
//
// - Every item setter (SetHyperlink item:, SetBagItem, SetInventoryItem,
// SetMerchantItem, SetAuctionItem, …) funnels through the shared item
// builder FUN_GAMETOOLTIP_BUILD_ITEM. We co-hook it and, after the build,
// fire the handler via FUN_FRAME_RUN_SCRIPT (the same no-arg runner the
// clear uses for OnTooltipCleared) with the tooltip as `self`.
//
// Suppressed during our own Tooltip::Compare equipped-item build (see
// Suppressor), and guarded against a handler that re-enters the builder.
#include "SetItemEvent.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Tooltip::SetItemEvent {
namespace {
// Per-tooltip 8-byte handler slot {handler, context}. Tooltips are few and
// permanent (GameTooltip, ItemRefTooltip, ShoppingTooltip1/2, AtlasLootTooltip,
// WorldMapTooltip, plus the odd addon tooltip), so a small fixed table with a
// linear scan is plenty and gives each cell a stable address to hand out.
struct Cell {
void *tooltip;
uint32_t slot[2]; // [0] = handler, [1] = exec context (written by SetScript)
};
constexpr int kMaxTooltips = 32;
Cell g_cells[kMaxTooltips];
int g_cellCount = 0;
uint32_t *CellFor(void *tooltip, bool create) {
for (int i = 0; i < g_cellCount; ++i)
if (g_cells[i].tooltip == tooltip)
return g_cells[i].slot;
if (!create || g_cellCount >= kMaxTooltips)
return nullptr;
Cell &c = g_cells[g_cellCount++];
c.tooltip = tooltip;
c.slot[0] = 0;
c.slot[1] = 0;
return c.slot;
}
bool EqualsIgnoreCase(const char *s, const char *literal) {
if (s == nullptr)
return false;
for (;; ++s, ++literal) {
unsigned char a = static_cast<unsigned char>(*s);
const unsigned char b = static_cast<unsigned char>(*literal);
if (a >= 'A' && a <= 'Z')
a = static_cast<unsigned char>(a + 32);
if (a != b)
return false;
if (b == 0)
return true;
}
}
constexpr char kScriptNameLower[] = "ontooltipsetitem"; // matched case-insensitively
// --- Suppression / recursion state -------------------------------------
int g_suppress = 0; // > 0 while our own compare build runs
int g_firing = 0; // recursion guard around the handler fire
// --- Resolver co-hook (CGGameTooltip vtable script-name → slot) ---------
using Resolver_t = int(__fastcall *)(void *self, void *edx, const char *name);
Resolver_t g_resolverOriginal = nullptr;
int __fastcall Resolver_h(void *self, void *edx, const char *name) {
const int engineSlot = g_resolverOriginal(self, edx, name);
if (engineSlot != 0) // a base-frame or existing tooltip script — leave it
return engineSlot;
if (EqualsIgnoreCase(name, kScriptNameLower))
return reinterpret_cast<int>(CellFor(self, /*create*/ true));
return 0;
}
// --- Item-builder co-hook (fires the handler after the item is set) -----
using Build_t = void(__fastcall *)(void *self, void *edx, uint32_t itemID, const void *guid,
const void *guid2, int a4, int a5, int a6, int headerFlag,
int a8, int a9);
Build_t g_buildOriginal = nullptr;
// The engine's real script invoker: binds the global `this` = frame and runs
// the handler under its own protected lua_pcall. We call it directly rather
// than the FUN_00702690 wrapper, whose exec-context mutation is unsafe from the
// deep nested Lua->C->Lua stack our co-hook fires within — see Offsets.h.
using InvokeScript_t = void(__fastcall *)(uint32_t handler, void *frame);
void __fastcall Build_h(void *self, void *edx, uint32_t itemID, const void *guid,
const void *guid2, int a4, int a5, int a6, int headerFlag, int a8,
int a9) {
g_buildOriginal(self, edx, itemID, guid, guid2, a4, a5, a6, headerFlag, a8, a9);
if (g_suppress > 0 || g_firing > 0)
return;
uint32_t *slot = CellFor(self, /*create*/ false);
if (slot == nullptr || slot[0] == 0)
return;
// We fire from inside the item builder, which is itself mid-execution of
// the engine's Script_Set* (and, in the wild, nested under addon Set*
// wrappers + several other DLLs' hooks). The invoker pushes onto the Lua
// stack and does NOT restore the top to where the outer, still-running C
// code left it — harmless from the engine's top-level dispatch, but from
// here it shifts the stack and the outer code (and other DLLs' hooks) then
// index garbage → deref → crash. Snapshot and restore the stack top around
// the fire so the outer code sees exactly the stack it left.
void *L = Game::Lua::State();
const int savedTop = (L != nullptr) ? Game::Lua::GetTop(L) : 0;
++g_firing;
reinterpret_cast<InvokeScript_t>(Offsets::FUN_FRAME_INVOKE_SCRIPT)(slot[0], self);
--g_firing;
if (L != nullptr)
Game::Lua::SetTop(L, savedTop);
}
static const Game::HookAutoRegister _resolverHook{
Offsets::FUN_GAMETOOLTIP_SCRIPT_RESOLVER,
reinterpret_cast<void *>(&Resolver_h),
reinterpret_cast<void **>(&g_resolverOriginal)};
static const Game::HookAutoRegister _buildHook{
Offsets::FUN_GAMETOOLTIP_BUILD_ITEM,
reinterpret_cast<void *>(&Build_h),
reinterpret_cast<void **>(&g_buildOriginal)};
} // namespace
Suppressor::Suppressor() { ++g_suppress; }
Suppressor::~Suppressor() {
if (g_suppress > 0)
--g_suppress;
}
} // namespace Tooltip::SetItemEvent
+30
View File
@@ -0,0 +1,30 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Tooltip::SetItemEvent {
// RAII guard that suppresses OnTooltipSetItem firing for the duration of an
// engine item-build we invoke *internally* (Tooltip::Compare building the
// equipped item into a shopping tooltip). Without it, a handler would run in
// the middle of our line-shift / delta rendering and see a half-built tooltip.
// Nestable (ref-counted).
struct Suppressor {
Suppressor();
~Suppressor();
Suppressor(const Suppressor &) = delete;
Suppressor &operator=(const Suppressor &) = delete;
};
} // namespace Tooltip::SetItemEvent
+21
View File
@@ -242,4 +242,25 @@ constexpr Entry kColors[] = {
constexpr int kColorCount = sizeof(kColors) / sizeof(kColors[0]);
// Looks up a color by `baseTag` and returns it as a packed 32-bit value.
// The DBC `argb` column decodes as `0xAARRGGBB` (see the byte-split note
// above) — which is also exactly the little-endian {b,g,r,a} byte order
// the engine's tooltip line-color setter (FUN_0077f750) reads, so the
// returned value can be handed straight to a colored AddLine. Returns
// `fallback` (default opaque white) when the tag isn't present. constexpr
// so callers can bind named color constants at compile time.
constexpr uint32_t ByTag(const char *tag, uint32_t fallback = 0xFFFFFFFFu) {
for (const auto &e : kColors) {
const char *a = e.baseTag;
const char *b = tag;
while (*a != '\0' && *a == *b) {
++a;
++b;
}
if (*a == '\0' && *b == '\0')
return static_cast<uint32_t>(e.argb);
}
return fallback;
}
} // namespace UI::ColorData