Compare commits

...
13 Commits
Author SHA1 Message Date
Brues 49865e9b9e Fix cast-then-channel spells showing the channel bar at cast start
Mind Control is a 3 s cast on a spell flagged CHANNELED with a 60 s
duration. HandleSpellStart branched on the channel attribute alone, so
at cast start it stamped g_channel with the 60 s duration and killed
the client hook's correct 3 s cast stamp — the bar showed a premature
60 s countdown (pfUI #9). Same story remotely: an observed MC stored a
60 s channel entry instead of the 3 s cast.

- Treat a channeled spell with castTime > 0 as its CAST phase in
  HandleSpellStart (player and remote paths); instant channels keep
  the immediate stamp.
- Co-hook the MSG_CHANNEL_START handler (0x006E7550, layout from
  nampower's SpellChannelStartHandlerHook: spellId u32, duration u32).
  The server sends it to the caster the moment the channel begins —
  after MC's cast completes — with the server-authoritative duration.
  Stamp g_channel there, before the original fires
  SPELLCAST_CHANNEL_START, so pollers see fresh data. For instant
  channels it re-stamps what the SpellStart path just wrote.

Verified: MC shows 3 s cast then 60 s channel; Mind Flay-style instant
channels unaffected.
2026-07-03 21:36:42 -05:00
Brues 98d4504b6f CBOR encode: keep encoding through OutOfMemory so the resize tally is complete
tinycbor's grow-and-retry contract: on buffer overflow the encoder
switches to counting mode and encoding must CONTINUE for
cbor_encoder_get_extra_bytes_needed() to be accurate — a nested
container's tally only reaches the parent via close_container. The old
code returned false on the first CborErrorOutOfMemory, so a mid-
container overflow could report extra == 0 and be misread as a genuine
"value not representable" failure (returning nil), or undercount and
churn through multiple resize passes.

Add EncOk() (NoError or OutOfMemory = keep going) around every encode
call; the retry loop now distinguishes real failures (return nil) from
overflow (single complete-tally resize, retry).
2026-07-03 20:48:39 -05:00
Brues 597530d558 Evict interrupted casts via SMSG_SPELL_FAILED_OTHER (TODO #98)
Remote casts cached from SMSG_SPELL_START were expired only by their
computed end time — an interrupted remote cast lingered as a ghost bar
for every UnitCastingInfo consumer (pfUI #11). The interrupt signal
does exist at the packet layer: the server broadcasts
SMSG_SPELL_FAILED_OTHER to observers whenever a started cast aborts
(interrupt, death, movement, fizzle).

Co-hook its handler (0x006E8E40, same __stdcall shape as
FUN_SPELL_DELAYED; address + layout mirrored from nampower's
SpellFailedOtherHandlerHook — body is plain casterGuid(u64),
spellId(u32)) and:

- evict the matching g_remoteCasts entry (guid + spellID), so remote
  cast/channel bars drop the same tick the cast dies;
- clear the player's own g_cast on a guid match — covers the
  server-stamped chained recast that the WorldTick VAR==0 poll can't
  clear (self-receipt is server-dependent; the endMs self-expiry
  backstop remains if it never fires).

Update the stale 'no per-unit interrupt signal in 1.12' notes in
Cast.cpp and docs/API.md.
2026-07-03 20:24:14 -05:00
Brues 2165a8e986 Restore TTS with VanillaTTS coexistence
Re-add the C_VoiceChat / C_TTSSettings SAPI module removed in 6f543ec,
gated so it coexists with the standalone VanillaTTS DLL: when
VanillaTTS.dll is loaded in the process, the module stands down
entirely — no Lua functions, no ttsVoice/ttsSpeed/ttsVolume cvars, no
VOICE_CHAT_TTS_* event reservations, no SAPI init. Without the gate,
each DLL would claim its own event-table slot for the same event names
(listeners bind to one slot while the other DLL fires the other) and
the cvar registrar would replace the first DLL's clamp callbacks.

The check (GetModuleHandleA at RegisterLuaFunctions time) is
load-order-independent — VanillaFixes loads every DLL at process
attach, long before FUN_LOAD_SCRIPT_FUNCTIONS fires. The event
AutoReserves move from file scope into the gated registration as
function-local statics so a stand-down leaves no reservations behind.

Restore the ole32 link, the README VoiceChat/API rows + event table,
and the docs/API.md VoiceChat section (with a coexistence note).
2026-07-03 19:00:17 -05:00
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
22 changed files with 3242 additions and 457 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";
+4 -1
View File
@@ -99,7 +99,10 @@ target_compile_definitions(
${PROJECT_NAME}
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32)
# ole32: COM (CoInitializeEx / CoCreateInstance / CoTaskMemFree) for the
# SAPI text-to-speech backend in src/voicechat/Tts.cpp. The SAPI GUIDs are
# instantiated in that TU via <initguid.h>, so no sapi.lib is needed.
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32)
target_include_directories(
${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
+14 -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` |
@@ -59,6 +59,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
### GlueXML calls
@@ -132,6 +133,10 @@ when launching with `-console`), not as Lua functions. See the
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers, utteranceID, destination` |
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* |
### Globals
@@ -165,6 +170,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.
+261 -5
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)
@@ -399,6 +402,14 @@ build instructions.
- [`C_UnitAuras.GetUnitAuras(unit [, filter])`](#c_unitaurasgetunitaurasunit--filter)
- [`C_UnitAuras.GetAuraDispelTypeColor(dispelName)`](#c_unitaurasgetauradispeltypecolordispelname)
- [VoiceChat](#voicechat)
- [`C_VoiceChat.GetTtsVoices()` / `C_VoiceChat.GetRemoteTtsVoices()`](#c_voicechatgetttsvoices--c_voicechatgetremotettsvoices)
- [`C_VoiceChat.SpeakText(voiceID, text [, destination, rate, volume])`](#c_voicechatspeaktextvoiceid-text--destination-rate-volume)
- [`C_VoiceChat.StopSpeakingText()`](#c_voicechatstopspeakingtext)
- [`C_TTSSettings` — getters & setters](#c_ttssettings--getters--setters)
- [TTS events](#tts-events)
- [TTS CVars](#tts-cvars)
## Account
### `SaveAccount(name, password)` / `DeleteAccount(name)` / `GetSavedAccounts()` / `LoginWithSavedAccount(name)` — GlueXML only
@@ -3421,6 +3432,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 +4860,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 +4869,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
@@ -8149,10 +8276,12 @@ so progress is `(GetTime()*1000 - startTimeMs) / (endTimeMs - startTimeMs)`.
> another unit's cast with a server-authoritative cast time, cached per
> caster GUID. So `C_Spell.UnitCastingInfo("target")` now backs
> enemy/target/focus/nameplate cast bars. Instant casts return `nil` (no
> cast bar). Best-effort for remote units, in three ways: only casts that
> began while the unit was in range are seen; since 1.12 has no per-unit
> interrupt packet to hook, an *interrupted* remote cast lingers until its
> computed `endTimeMs` rather than clearing instantly; and the remote
> cast bar). Interrupted remote casts clear the same tick — a co-hook on
> `SMSG_SPELL_FAILED_OTHER` (the packet the server broadcasts to observers
> when a started cast aborts: interrupt, death, movement, fizzle) evicts
> the cached cast immediately, so no ghost bar runs to completion.
> Best-effort for remote units, in two ways: only casts that began while
> the unit was in range are seen; and the remote
> `startTimeMs`/`endTimeMs` are shifted later by roughly your latency.
> (1.12's `SMSG_SPELL_START` carries only a single `castTime`, so we stamp
> `start = now, end = now + castTime` on receipt. 3.3.5+ avoids the skew
@@ -9644,3 +9773,130 @@ builds. So the returned value carries the mixin methods
`.r/.g/.b/.a` fields. Falls back to a plain `{r,g,b,a}` table if
`CreateColor` isn't loaded yet (shouldn't happen — `!!!ClassicAPI`
loads first thanks to the triple-`!` prefix).
## VoiceChat
Backport of modern WoW's text-to-speech surface — the `C_VoiceChat` and
`C_TTSSettings` namespaces — backed by **Windows SAPI** (the OS speech
engine). All playback is **local** (rendered on your own machine);
vanilla 1.12 has no voice-chat transport, so there is nothing "remote" to
transmit synthesized speech into.
> **Coexistence with VanillaTTS:** the same surface also ships as the
> standalone [VanillaTTS](https://github.com/brues-code/VanillaTTS) DLL,
> which adds a software-synth backend for Wine. If `VanillaTTS.dll` is
> loaded in the same client, this built-in module stands down automatically
> (registers none of the functions, events, or cvars below) and VanillaTTS
> provides them instead — the Lua-visible API is identical either way, so
> addons don't need to care which one is active.
SAPI is created lazily on first use. If SAPI is unavailable (e.g. running
the client under Wine, or a stripped Windows), every call degrades
gracefully: `GetTtsVoices` returns an empty table, `SpeakText` fires
`VOICE_CHAT_TTS_PLAYBACK_FAILED` and does nothing, and the client stays
stable — there is no crash. Install more voices under **Windows Settings →
Time & Language → Speech**, then call `C_TTSSettings.RefreshVoices()`.
### `C_VoiceChat.GetTtsVoices()` / `C_VoiceChat.GetRemoteTtsVoices()`
Return a numerically-indexed array of voice tables — one per SAPI voice
installed on the machine:
```lua
for _, v in ipairs(C_VoiceChat.GetTtsVoices()) do
print(v.voiceID, v.name) -- e.g. 0 "Microsoft David Desktop - English (United States)"
end
```
Each entry is `{ voiceID = <0-based index>, name = <display name> }`. The
`voiceID` is the index you pass to `SpeakText` and store via
`C_TTSSettings.SetVoiceOption`. `GetRemoteTtsVoices` is an alias returning
the same local list (kept for API parity — vanilla has no voice chat).
Calling either refreshes the cached voice list and fires
`VOICE_CHAT_TTS_VOICES_UPDATE` if it changed.
### `C_VoiceChat.SpeakText(voiceID, text [, destination, rate, volume])`
Speaks `text` aloud using the SAPI voice at index `voiceID`.
```lua
C_VoiceChat.SpeakText(
C_TTSSettings.GetSpeechVoiceID(), -- use the configured voice
UnitName("target") or "no target")
```
- **`voiceID`** (number, required) — index into `GetTtsVoices()`. Note this
is an **explicit argument** — `SpeakText` does *not* read the `ttsVoice`
cvar itself, matching Blizzard's contract. Pass
`C_TTSSettings.GetSpeechVoiceID()` to honor the configured default.
- **`text`** (string, required) — UTF-8; spoken as-is.
- **`destination`** (number, optional, default `1`) — `1` =
`LOCAL_PLAYBACK`, `4` = `QUEUED_LOCAL_PLAYBACK`. Accepted for parity;
both queue FIFO through SAPI (no app-side queue).
- **`rate`** (number, optional, default `0`) — speech rate, clamped to
`[-10, 10]`.
- **`volume`** (number, optional, default `100`) — clamped to `[0, 100]`.
Speech is asynchronous. The call returns immediately and fires
`VOICE_CHAT_TTS_PLAYBACK_STARTED` when SAPI begins the utterance and
`VOICE_CHAT_TTS_PLAYBACK_FINISHED` when it completes (or
`..._PLAYBACK_FAILED` if the voice/engine is unavailable). Raises a Lua
error if `voiceID` isn't a number or `text` isn't a string.
### `C_VoiceChat.StopSpeakingText()`
Stops and purges everything currently playing or queued.
### `C_TTSSettings` — getters & setters
The settings store (separate from `SpeakText`, which takes its parameters
explicitly). All three values are backed by CVars (see below), so they
**persist across sessions** and are clamped to valid ranges on write.
| Function | Returns / effect |
|---|---|
| `C_TTSSettings.GetSpeechRate()` | configured rate, `[-10, 10]` (default `0`) |
| `C_TTSSettings.GetSpeechVolume()` | configured volume, `[0, 100]` (default `100`) |
| `C_TTSSettings.GetSpeechVoiceID()` | configured voice index (default `0`) |
| `C_TTSSettings.GetVoiceOptionName()` | display name of the configured voice, or `""` |
| `C_TTSSettings.SetSpeechRate(rate)` | set rate (clamped) |
| `C_TTSSettings.SetSpeechVolume(volume)` | set volume (clamped) |
| `C_TTSSettings.SetVoiceOption(voiceID)` | set the voice by index (clamped to installed count) |
| `C_TTSSettings.SetVoiceOptionByName(name)` | set the voice by display name (case-insensitive exact match) |
| `C_TTSSettings.SetDefaultSettings()` | reset to voice `1` (if present, else `0`), rate `0`, volume `100` |
| `C_TTSSettings.RefreshVoices()` | re-enumerate SAPI voices; fires `VOICE_CHAT_TTS_VOICES_UPDATE` if the list changed |
```lua
C_TTSSettings.SetVoiceOptionByName("Microsoft Zira Desktop - English (United States)")
C_TTSSettings.SetSpeechVolume(150) -- clamps to 100
print(C_TTSSettings.GetVoiceOptionName()) -- "Microsoft Zira Desktop - ..."
```
### TTS events
Registered like any engine event (`frame:RegisterEvent("VOICE_CHAT_TTS_PLAYBACK_STARTED")`).
| Event | Args |
|---|---|
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers` (number), `utteranceID` (number), `durationMS` (number, always `0`), `destination` (number) |
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers` (number), `utteranceID` (number), `destination` (number) |
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status` (string — e.g. `"EngineAllocationFailed"`, `"InternalError"`), `utteranceID` (number), `destination` (number) |
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* — the installed-voice list changed |
| `VOICE_CHAT_TTS_SPEAK_TEXT_UPDATE` | reserved for parity; not currently fired |
`utteranceID` correlates the START/FINISHED/FAILED events for a given
`SpeakText` call. `PLAYBACK_FINISHED` fires only when playback actually
completes, so it's the signal to start the next utterance in a queue.
### TTS CVars
The `C_TTSSettings` values are stored as engine CVars, persisted to
`WTF\Config.wtf`. You can also read/write them with the standard
`GetCVar`/`SetCVar` (or `/console set`); writes are clamped by the same
validation:
| CVar | Default | Range | Backing setting |
|---|---|---|---|
| `ttsVoice` | `0` | `[0, voiceCount-1]` | `GetSpeechVoiceID` / `SetVoiceOption` |
| `ttsSpeed` | `0` | `[-10, 10]` | `GetSpeechRate` / `SetSpeechRate` |
| `ttsVolume` | `100` | `[0, 100]` | `GetSpeechVolume` / `SetSpeechVolume` |
+174 -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
@@ -740,6 +876,28 @@ enum Offsets {
// reflects pushback. Mirrored from nampower's SpellDelayedHook.
FUN_SPELL_DELAYED = 0x006E74F0,
// `MSG_CHANNEL_START` handler — `int __stdcall(uint32_t *opCode,
// CDataStore *packet)`. Body: `spellId(u32), durationMs(u32)`. Sent
// only to the caster when their channel begins, with the server-
// authoritative duration. `Spell::Cast` co-hooks it to stamp the
// player's channel — required for cast-then-channel spells (Mind
// Control: 3 s cast then 60 s channel), whose SMSG_SPELL_START only
// covers the cast phase. Address + layout mirrored from nampower's
// SpellChannelStartHandlerHook (they hook it too — accepted co-hook
// collision, per-channel-start frequency).
FUN_SPELL_CHANNEL_START = 0x006E7550,
// `SMSG_SPELL_FAILED_OTHER` handler — `int __stdcall(uint32_t *opCode,
// CDataStore *packet)`, same shape as FUN_SPELL_DELAYED. Body:
// `casterGuid(u64, plain), spellId(u32)`. Broadcast to observers when a
// started cast aborts (interrupt, death, movement, fizzle) — the ONLY
// per-unit interrupt signal 1.12 has at the packet layer. `Spell::Cast`
// co-hooks it to invalidate the matching remote-cast cache entry (and
// the player's own server-stamped chained recast) so an interrupted
// cast doesn't linger as a ghost bar until its computed end. Address +
// packet layout mirrored from nampower's SpellFailedOtherHandlerHook.
FUN_SPELL_FAILED_OTHER = 0x006E8E40,
// `CGUnit_C::OnAuraAdded` — `__thiscall void(CGUnit *unit, uint32_t
// slot, uint32_t spellId)` (i.e. `__fastcall(unit /*ecx*/, edx_unused,
// slot, spellId)`). Fires for EVERY aura that lands on a tracked unit,
@@ -1045,6 +1203,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 +4276,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
+29 -26
View File
@@ -30,6 +30,16 @@ constexpr int kMaxDepth = 64;
// ---------------- encode ----------------
// tinycbor's grow-and-retry contract: on buffer overflow the encoder
// switches to counting mode (end = NULL, accumulates bytes_needed) and
// encoding must CONTINUE so the final tally is complete — a nested
// container's tally only reaches the parent via close_container. So
// CborErrorOutOfMemory is not a failure here; the caller reads
// cbor_encoder_get_extra_bytes_needed() afterwards, resizes, retries.
bool EncOk(CborError err) {
return err == CborNoError || err == CborErrorOutOfMemory;
}
bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth);
bool IsLuaArray(void *L, int idx, int &outLen) {
@@ -73,8 +83,8 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
int arrayLen = 0;
if (IsLuaArray(L, idx, arrayLen)) {
CborEncoder arr;
if (cbor_encoder_create_array(enc, &arr,
static_cast<size_t>(arrayLen)) != CborNoError)
if (!EncOk(cbor_encoder_create_array(enc, &arr,
static_cast<size_t>(arrayLen))))
return false;
for (int i = 1; i <= arrayLen; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i));
@@ -85,12 +95,12 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
}
Game::Lua::SetTop(L, -2);
}
return cbor_encoder_close_container(enc, &arr) == CborNoError;
return EncOk(cbor_encoder_close_container(enc, &arr));
}
const int pairs = CountTablePairs(L, idx);
CborEncoder map;
if (cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs)) != CborNoError)
if (!EncOk(cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs))))
return false;
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, idx) != 0) {
@@ -101,16 +111,16 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
}
Game::Lua::SetTop(L, -2);
}
return cbor_encoder_close_container(enc, &map) == CborNoError;
return EncOk(cbor_encoder_close_container(enc, &map));
}
bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth) {
switch (Game::Lua::Type(L, idx)) {
case Game::Lua::TYPE_NIL:
return cbor_encode_null(enc) == CborNoError;
return EncOk(cbor_encode_null(enc));
case Game::Lua::TYPE_BOOLEAN:
return cbor_encode_boolean(enc,
Game::Lua::ToBoolean(L, idx) != 0) == CborNoError;
return EncOk(cbor_encode_boolean(enc,
Game::Lua::ToBoolean(L, idx) != 0));
case Game::Lua::TYPE_NUMBER: {
const double n = Game::Lua::ToNumber(L, idx);
// Whole numbers in [-2^53, 2^53] go out as integers so
@@ -118,17 +128,17 @@ bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth) {
if (std::isfinite(n) && n == std::floor(n) &&
n >= -9007199254740992.0 && n <= 9007199254740992.0) {
if (n >= 0.0)
return cbor_encode_uint(enc,
static_cast<uint64_t>(n)) == CborNoError;
return cbor_encode_negative_int(enc,
static_cast<uint64_t>(-n)) == CborNoError;
return EncOk(cbor_encode_uint(enc,
static_cast<uint64_t>(n)));
return EncOk(cbor_encode_negative_int(enc,
static_cast<uint64_t>(-n)));
}
return cbor_encode_double(enc, n) == CborNoError;
return EncOk(cbor_encode_double(enc, n));
}
case Game::Lua::TYPE_STRING: {
const char *s = Game::Lua::ToString(L, idx);
const unsigned int n = Game::Lua::StrLen(L, idx);
return cbor_encode_text_string(enc, s, n) == CborNoError;
return EncOk(cbor_encode_text_string(enc, s, n));
}
case Game::Lua::TYPE_TABLE:
return EncodeTable(L, idx, enc, depth);
@@ -143,22 +153,15 @@ int __fastcall Script_SerializeCBOR(void *L) {
return 0;
}
// tinycbor encodes into a fixed-size buffer; if it overflows, it
// tracks the extra bytes needed so we can resize and retry.
// tinycbor encodes into a fixed-size buffer; on overflow it keeps
// encoding in counting mode (EncOk treats OutOfMemory as success) so
// the extra-bytes tally is complete, then we resize and retry once.
std::vector<uint8_t> buf(256);
for (;;) {
CborEncoder enc;
cbor_encoder_init(&enc, buf.data(), buf.size(), 0);
if (!EncodeValue(L, 1, &enc, 0)) {
// If it was a buffer-size issue we'll catch it via the
// extra-bytes-needed check below; otherwise this is a
// genuine "value not representable" failure.
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
if (extra == 0)
return 0;
buf.resize(buf.size() + extra);
continue;
}
if (!EncodeValue(L, 1, &enc, 0))
return 0; // genuine "value not representable" failure
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
if (extra > 0) {
buf.resize(buf.size() + extra);
+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
+113 -14
View File
@@ -32,11 +32,15 @@
// time), dedup'd against a just-made client stamp so a normal cast's
// confirming packet doesn't restart its bar. `g_castFromServer = true`;
// since the global was never set for these, the WorldTick VAR==0 clear
// must skip them — they expire on their computed endMs (and pfUI clears
// on nampower's SPELL_FAILED_SELF for interrupts).
// - channel: the player's broadcast `UNIT_FIELD_CHANNEL_SPELL`
// (descriptor +0x228), polled on `WorldTick`. endMs = now + effective
// channel duration.
// must skip them — they expire on their computed endMs, or earlier via
// the SMSG_SPELL_FAILED_OTHER co-hook when the server tells us the cast
// aborted.
// - channel: stamped from `SMSG_SPELL_START` at packet receipt (instant
// channels; engine-computed duration) and from `MSG_CHANNEL_START`
// (server duration — and the ONLY start signal for cast-then-channel
// spells like Mind Control, whose SpellStart covers just the cast
// phase). The broadcast +0x228 field is NOT polled for the player —
// it lags the channel start by ~1 tick.
// `now` is the engine ms clock (`FUN_OS_TICKCOUNT_MS`), the same source
// Lua's `GetTime()` scales by 0.001 — so startMs/endMs are directly
// comparable to `GetTime()*1000` in addon progress math.
@@ -47,8 +51,10 @@
// cache — see the SpellStart co-hook below. Scope:
// - Player casts/channels: full timing (self-tracked, exact engine math).
// - Other units' regular CASTS: full timing while within the cast window,
// from the SpellStart cache. No clean per-unit interrupt signal exists
// in 1.12, so an interrupted remote cast lingers until its computed end.
// from the SpellStart cache. Interrupts are handled by the
// SMSG_SPELL_FAILED_OTHER co-hook, which invalidates the cache entry
// the moment the server reports the cast aborted (interrupt, death,
// movement, fizzle) — no lingering ghost bar.
// - Other units' CHANNELS: the broadcast +0x228 field gates "channeling
// now"; the SpellStart cache adds real start/end times when we observed
// the channel begin (else spellID/name/texture with nil times).
@@ -332,7 +338,7 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
const bool channel = IsChannelSpell(rec);
if (caster == Unit::Identity::PlayerGuid()) {
if (channel) {
if (channel && castTime == 0) {
// Player channel start. The broadcast +0x228 field that signals
// "channeling" is server-pushed and lands ~1 tick (~1s) after the
// channel actually begins, so a +0x228 poll shows the bar far too
@@ -341,7 +347,8 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
// now with the engine-computed duration. We run before the
// original handler that fires SPELLCAST_CHANNEL_START, so the data
// is fresh when addons poll. Cleared by self-expiry (PushChannel-
// Info) at endMs.
// Info) at endMs. (MSG_CHANNEL_START re-stamps moments later with
// the server's duration — see ChannelStart_h.)
const int dur = ChannelDurationMs(spellID);
if (dur > 0) {
g_channel = TrackedSpell{spellID, now, now + dur, 0};
@@ -349,6 +356,12 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
}
return;
}
// channel && castTime > 0 → a CAST-THEN-CHANNEL spell (Mind Control:
// 3 s cast, then a 60 s channel). This packet is the cast phase —
// the channel begins only when the cast completes, signaled by
// MSG_CHANNEL_START (ChannelStart_h). Fall through to regular cast
// handling so the bar shows the 3 s cast, not a premature 60 s
// channel (pfUI #9).
if (castTime == 0)
return; // instant — no cast bar
// Backstop for chained same-spell recasts: the engine short-circuits
@@ -367,9 +380,14 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
g_channel.spellID = 0; // a cast supersedes any channel
return;
}
const int endMs = channel ? now + RemoteChannelDurationMs(rec)
: now + static_cast<int>(castTime);
StoreRemoteCast(caster, spellID, now, endMs, channel);
// A channeled spell with a cast time (Mind Control) is in its CAST phase
// here — store it as a regular cast so observers see the 3 s bar. The
// remote channel phase has no packet (MSG_CHANNEL_START is caster-only);
// it surfaces through the unit's broadcast +0x228 field as usual.
const bool instantChannel = channel && castTime == 0;
const int endMs = instantChannel ? now + RemoteChannelDurationMs(rec)
: now + static_cast<int>(castTime);
StoreRemoteCast(caster, spellID, now, endMs, instantChannel);
}
// Co-hook on the SMSG_SPELL_START handler. Gated on opCode 0x131 so every
@@ -427,6 +445,87 @@ const Game::HookAutoRegister _spellDelayedHook{
Offsets::FUN_SPELL_DELAYED, reinterpret_cast<void *>(&SpellDelayed_h),
reinterpret_cast<void **>(&g_origSpellDelayed)};
// Co-hook on the MSG_CHANNEL_START handler — the server tells the caster a
// channel has begun, with the server-authoritative (modifier-applied)
// duration. Sent only to the caster, so it's always the local player. This
// is what makes CAST-THEN-CHANNEL spells (Mind Control: 3 s cast, then a
// 60 s channel) show both phases: their SMSG_SPELL_START carries the cast
// (castTime > 0, handled as a regular cast in HandleSpellStart) and only
// this packet marks the channel actually starting. For instant channels it
// re-stamps what the SpellStart path just wrote (same moment, server
// duration) — harmless. We stamp before the original runs so the data is
// fresh when the engine fires SPELLCAST_CHANNEL_START. Packet layout
// mirrored from nampower's SpellChannelStartHandlerHook:
// spellId(u32), durationMs(u32).
using ChannelStart_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
ChannelStart_t g_origChannelStart = nullptr;
int __stdcall ChannelStart_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
const uint32_t duration = Net::Read<uint32_t>(packet);
packet->m_read = saved;
if (spellID != 0 && duration > 0) {
const int now = NowMs();
g_channel =
TrackedSpell{spellID, now, now + static_cast<int>(duration), 0};
g_cast.spellID = 0; // the channel supersedes the completed cast
}
}
return g_origChannelStart(opCode, packet);
}
const Game::HookAutoRegister _channelStartHook{
Offsets::FUN_SPELL_CHANNEL_START,
reinterpret_cast<void *>(&ChannelStart_h),
reinterpret_cast<void **>(&g_origChannelStart)};
// Co-hook on the SMSG_SPELL_FAILED_OTHER handler — the server broadcasts it
// to observers whenever a started cast aborts (interrupt, death, movement,
// fizzle). It's the only per-unit interrupt signal 1.12 has, so it's what
// lets us drop a remote cast the moment it dies instead of letting the
// ghost bar run to its computed end (the documented remote-unit limitation
// above). Packet body is plain (not packed): casterGuid(u64), spellId(u32).
using SpellFailedOther_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
SpellFailedOther_t g_origSpellFailedOther = nullptr;
int __stdcall SpellFailedOther_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint64_t guid = Net::Read<uint64_t>(packet);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
packet->m_read = saved;
if (guid != 0 && spellID != 0) {
// Remote units: invalidate the cached cast/channel so
// UnitCastingInfo/UnitChannelInfo stop reporting it this tick.
for (auto &e : g_remoteCasts) {
if (e.used && e.casterGuid == guid && e.spellID == spellID) {
e.used = false;
break;
}
}
// Local player: a server-stamped chained recast
// (g_castFromServer) never set VAR_CURRENT_CAST_SPELL, so the
// WorldTick VAR==0 poll can't clear it on interrupt — this
// packet is the only signal. Whether the caster receives their
// own broadcast is server-dependent; if it never fires, the
// endMs self-expiry backstop still applies.
if (guid == Unit::Identity::PlayerGuid() &&
g_cast.spellID == spellID)
g_cast.spellID = 0;
}
}
return g_origSpellFailedOther(opCode, packet);
}
const Game::HookAutoRegister _spellFailedOtherHook{
Offsets::FUN_SPELL_FAILED_OTHER,
reinterpret_cast<void *>(&SpellFailedOther_h),
reinterpret_cast<void **>(&g_origSpellFailedOther)};
} // namespace
// Per-frame upkeep for the player. The regular cast is stamped by the
@@ -458,8 +557,8 @@ static int __fastcall Script_CastingInfo(void *L) { return PushCastInfo(L, g_cas
// `UnitCastingInfo(unit)` — local player from self-tracking; other units
// from the SMSG_SPELL_START cache (regular casts still within their cast
// window). An interrupted remote cast lingers until its computed end time
// (no clean per-unit interrupt signal in 1.12) — acceptable best-effort.
// window; interrupted casts are evicted by the SMSG_SPELL_FAILED_OTHER
// co-hook).
static int __fastcall Script_UnitCastingInfo(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_Spell.UnitCastingInfo(\"unit\")");
+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
+594
View File
@@ -0,0 +1,594 @@
// 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/>.
// Text-to-speech — a Windows SAPI backend behind modern WoW's C_VoiceChat /
// C_TTSSettings Lua surface. Ported from awesome_wotlk's VoiceChat.cpp (the
// 3.3.5 implementation); the SAPI core is client-version-agnostic, so only
// the three integration seams are ClassicAPI-native:
// - namespaces → Game::Lua::RegisterTableFunction
// - events → Event::Custom (AutoReserve + Fire)
// - settings → CVar::Factory (ttsVoice / ttsSpeed / ttsVolume, persisted
// to Config.wtf, clamped via change callbacks)
//
// Lua surface:
// C_VoiceChat
// GetTtsVoices() -> { {voiceID=, name=}, ... }
// GetRemoteTtsVoices() -> same (vanilla has no real voice chat)
// SpeakText(voiceID, text[, destination[, rate[, volume]]])
// StopSpeakingText()
// C_TTSSettings
// GetSpeechRate() / GetSpeechVolume() / GetSpeechVoiceID()
// GetVoiceOptionName()
// SetDefaultSettings()
// SetSpeechRate(v) / SetSpeechVolume(v)
// SetVoiceOption(id) / SetVoiceOptionByName(name)
// RefreshVoices()
//
// Events: VOICE_CHAT_TTS_PLAYBACK_STARTED/FINISHED/FAILED,
// VOICE_CHAT_TTS_SPEAK_TEXT_UPDATE (reserved, unused),
// VOICE_CHAT_TTS_VOICES_UPDATE.
//
// Threading: SAPI is created on the main (game) thread under an STA, and
// SetNotifyCallbackFunction marshals the notify back to that thread's message
// pump (WoW pumps messages every frame), so OnSpeechNotify runs on the main
// thread and may touch the Lua event dispatcher safely.
//
// Coexistence: the same TTS surface also ships as the standalone VanillaTTS
// DLL (which adds a pluggable backend — software synth under Wine). When
// that DLL is injected alongside us, this module stands down entirely — see
// StandaloneTtsPresent below.
#include "Game.h"
#include "cvar/Factory.h"
#include "event/Custom.h"
#include <initguid.h> // instantiate the SAPI GUIDs in this TU (no sapi.lib)
#include <sapi.h>
#include <windows.h>
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cwctype>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
namespace VoiceChat::Tts {
namespace {
// ---------------------------------------------------------------------------
// Standalone-DLL coexistence
// ---------------------------------------------------------------------------
// The dedicated VanillaTTS DLL provides this exact Lua surface (same
// C_VoiceChat / C_TTSSettings functions, same VOICE_CHAT_TTS_* events, same
// ttsVoice/ttsSpeed/ttsVolume cvars) with a more capable backend. If both
// registered, the damage wouldn't be limited to "last one wins":
// - Each DLL claims its OWN event-table slot for the same event name, so
// the table ends up with duplicate-name entries. frame:RegisterEvent
// binds listeners to whichever slot the engine's scan finds first, while
// the other DLL fires the other slot — events silently lost depending on
// load order.
// - The cvar registrar dedups by name but REPLACES the change callback on
// re-register, so one DLL's clamp callbacks (and userData) go dead.
// - Both would init COM/SAPI and answer SpeakText → double playback risk.
// So when VanillaTTS.dll is in the process, this module registers nothing —
// no Lua functions, no cvars, no event reservations, no SAPI init.
//
// The check runs at RegisterLuaFunctions time (first login), which is
// load-order-independent: VanillaFixes loads every DLL at process attach,
// long before FUN_LOAD_SCRIPT_FUNCTIONS first fires. Latched once — modules
// can't appear or vanish mid-session.
bool StandaloneTtsPresent() {
static const bool present = GetModuleHandleA("VanillaTTS.dll") != nullptr;
return present;
}
// ---------------------------------------------------------------------------
// Event names — reserved lazily in RegisterLuaFunctions (NOT via file-scope
// AutoReserve statics, which would run unconditionally at DLL load and claim
// slots even when we're standing down for VanillaTTS).
// ---------------------------------------------------------------------------
constexpr const char *kEvtPlaybackFailed = "VOICE_CHAT_TTS_PLAYBACK_FAILED";
constexpr const char *kEvtPlaybackFinished = "VOICE_CHAT_TTS_PLAYBACK_FINISHED";
constexpr const char *kEvtPlaybackStarted = "VOICE_CHAT_TTS_PLAYBACK_STARTED";
constexpr const char *kEvtSpeakTextUpdate = "VOICE_CHAT_TTS_SPEAK_TEXT_UPDATE";
constexpr const char *kEvtVoicesUpdate = "VOICE_CHAT_TTS_VOICES_UPDATE";
// ---------------------------------------------------------------------------
// CVar names + handles (registered in RegisterLuaFunctions)
// ---------------------------------------------------------------------------
constexpr const char *kCVarVoice = "ttsVoice";
constexpr const char *kCVarSpeed = "ttsSpeed";
constexpr const char *kCVarVolume = "ttsVolume";
CVar::Factory::Handle g_cvarVoice = nullptr;
CVar::Factory::Handle g_cvarSpeed = nullptr;
CVar::Factory::Handle g_cvarVolume = nullptr;
// Destination constants (kept for API parity; no special handling — SAPI
// queues FIFO either way).
enum : int {
DEST_LOCAL_PLAYBACK = 1,
DEST_QUEUED_LOCAL_PLAYBACK = 4,
};
int ClampDestination(int d) {
return d == DEST_QUEUED_LOCAL_PLAYBACK ? DEST_QUEUED_LOCAL_PLAYBACK
: DEST_LOCAL_PLAYBACK;
}
// ---------------------------------------------------------------------------
// SAPI state
// ---------------------------------------------------------------------------
ISpVoice *g_pVoice = nullptr;
bool g_comInit = false;
int g_nextUtteranceID = 1;
int NextUtteranceID() {
if (g_nextUtteranceID >= 0x7fffffff)
g_nextUtteranceID = 1;
return g_nextUtteranceID++;
}
struct UtteranceMeta {
int id;
int destination;
bool startedEmitted;
};
std::mutex g_streamMx;
std::unordered_map<ULONG, UtteranceMeta> g_streamMap;
struct VoiceEntry {
int voiceID;
std::wstring name;
};
std::vector<VoiceEntry> g_cachedVoices;
// ---------------------------------------------------------------------------
// UTF-8 / wide conversion
// ---------------------------------------------------------------------------
std::string WideToUtf8(const std::wstring &w) {
if (w.empty())
return {};
int need = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, nullptr, 0, nullptr, nullptr);
if (need <= 1)
return {};
std::string out(static_cast<size_t>(need - 1), '\0');
WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, &out[0], need - 1, nullptr, nullptr);
return out;
}
std::wstring Utf8ToWide(const char *s) {
if (s == nullptr || *s == '\0')
return {};
int need = MultiByteToWideChar(CP_UTF8, 0, s, -1, nullptr, 0);
if (need <= 1)
return {};
std::wstring out(static_cast<size_t>(need - 1), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s, -1, &out[0], need);
return out;
}
// ---------------------------------------------------------------------------
// Event fire helpers (engine dispatcher via Event::Custom)
// ---------------------------------------------------------------------------
void FireVoicesUpdate() {
Event::Custom::Fire(Event::Custom::Lookup(kEvtVoicesUpdate), "");
}
void FirePlaybackStarted(int numConsumers, int utteranceID, int durationMS, int dest) {
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackStarted), "%d%d%d%d",
numConsumers, utteranceID, durationMS, dest);
}
void FirePlaybackFinished(int numConsumers, int utteranceID, int dest) {
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackFinished), "%d%d%d",
numConsumers, utteranceID, dest);
}
void FirePlaybackFailed(const char *status, int utteranceID, int dest) {
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackFailed), "%s%d%d",
status, utteranceID, dest);
}
// ---------------------------------------------------------------------------
// COM / SAPI lifecycle
// ---------------------------------------------------------------------------
void InitCom() {
if (g_comInit)
return;
HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
// S_FALSE = already initialized on this thread; treat as success.
if (SUCCEEDED(hr) || hr == RPC_E_CHANGED_MODE)
g_comInit = true;
}
void __stdcall OnSpeechNotify(WPARAM, LPARAM) {
if (g_pVoice == nullptr)
return;
SPEVENT ev = {};
ULONG fetched = 0;
while (SUCCEEDED(g_pVoice->GetEvents(1, &ev, &fetched)) && fetched == 1) {
if (ev.eEventId == SPEI_START_INPUT_STREAM) {
std::lock_guard<std::mutex> lock(g_streamMx);
auto it = g_streamMap.find(ev.ulStreamNum);
if (it != g_streamMap.end() && !it->second.startedEmitted) {
FirePlaybackStarted(1, it->second.id, /*durationMS*/ 0,
it->second.destination);
it->second.startedEmitted = true;
}
} else if (ev.eEventId == SPEI_END_INPUT_STREAM) {
UtteranceMeta meta{0, DEST_LOCAL_PLAYBACK, false};
{
std::lock_guard<std::mutex> lock(g_streamMx);
auto it = g_streamMap.find(ev.ulStreamNum);
if (it != g_streamMap.end()) {
meta = it->second;
g_streamMap.erase(it);
}
}
FirePlaybackFinished(1, meta.id, meta.destination);
}
// Only START/END stream events are requested; neither carries an
// allocated lParam, so no SpClearEvent (sphelper) is needed.
ev = SPEVENT{};
}
}
void InitVoice() {
if (g_pVoice != nullptr)
return;
InitCom();
HRESULT hr = CoCreateInstance(CLSID_SpVoice, nullptr, CLSCTX_ALL,
IID_ISpVoice, reinterpret_cast<void **>(&g_pVoice));
if (FAILED(hr) || g_pVoice == nullptr) {
g_pVoice = nullptr;
return;
}
g_pVoice->SetNotifyCallbackFunction(&OnSpeechNotify, 0, 0);
const ULONGLONG interest =
SPFEI(SPEI_START_INPUT_STREAM) | SPFEI(SPEI_END_INPUT_STREAM);
g_pVoice->SetInterest(interest, interest);
}
// ---------------------------------------------------------------------------
// Voice enumeration (CLSID_SpObjectTokenCategory; no sphelper)
// ---------------------------------------------------------------------------
std::vector<VoiceEntry> EnumerateVoices() {
InitCom();
std::vector<VoiceEntry> voices;
ISpObjectTokenCategory *cat = nullptr;
if (FAILED(CoCreateInstance(CLSID_SpObjectTokenCategory, nullptr, CLSCTX_ALL,
IID_ISpObjectTokenCategory,
reinterpret_cast<void **>(&cat))) ||
cat == nullptr)
return voices;
if (SUCCEEDED(cat->SetId(SPCAT_VOICES, FALSE))) {
IEnumSpObjectTokens *en = nullptr;
if (SUCCEEDED(cat->EnumTokens(nullptr, nullptr, &en)) && en != nullptr) {
ISpObjectToken *tok = nullptr;
ULONG fetched = 0;
int index = 0;
while (en->Next(1, &tok, &fetched) == S_OK && fetched == 1) {
LPWSTR desc = nullptr;
if (SUCCEEDED(tok->GetStringValue(nullptr, &desc)) && desc != nullptr) {
voices.push_back({index, desc});
CoTaskMemFree(desc);
}
tok->Release();
++index;
}
en->Release();
}
}
cat->Release();
return voices;
}
int VoiceCount() {
return static_cast<int>(EnumerateVoices().size());
}
void RefreshVoices() {
auto fresh = EnumerateVoices();
bool changed = fresh.size() != g_cachedVoices.size();
if (!changed) {
for (size_t i = 0; i < fresh.size(); ++i) {
if (fresh[i].name != g_cachedVoices[i].name) {
changed = true;
break;
}
}
}
g_cachedVoices = std::move(fresh);
if (changed)
FireVoicesUpdate();
}
// Select the voice at enumeration index `voiceID` on g_pVoice.
bool SelectVoice(int voiceID) {
ISpObjectTokenCategory *cat = nullptr;
if (FAILED(CoCreateInstance(CLSID_SpObjectTokenCategory, nullptr, CLSCTX_ALL,
IID_ISpObjectTokenCategory,
reinterpret_cast<void **>(&cat))) ||
cat == nullptr)
return false;
bool set = false;
if (SUCCEEDED(cat->SetId(SPCAT_VOICES, FALSE))) {
IEnumSpObjectTokens *en = nullptr;
if (SUCCEEDED(cat->EnumTokens(nullptr, nullptr, &en)) && en != nullptr) {
ISpObjectToken *tok = nullptr;
ULONG fetched = 0;
int index = 0;
while (en->Next(1, &tok, &fetched) == S_OK && fetched == 1) {
if (index == voiceID) {
set = SUCCEEDED(g_pVoice->SetVoice(tok));
tok->Release();
break;
}
tok->Release();
++index;
}
en->Release();
}
}
cat->Release();
return set;
}
// ---------------------------------------------------------------------------
// Speak / stop
// ---------------------------------------------------------------------------
void SpeakText(int voiceID, const std::wstring &text, int destination, int rate,
int volume) {
const int utteranceID = NextUtteranceID();
const int dest = ClampDestination(destination);
InitVoice();
if (g_pVoice == nullptr) {
FirePlaybackFailed("EngineAllocationFailed", utteranceID, dest);
return;
}
if (!SelectVoice(voiceID)) {
FirePlaybackFailed("InternalError", utteranceID, dest);
return;
}
g_pVoice->SetRate(std::clamp(rate, -10, 10));
g_pVoice->SetVolume(static_cast<USHORT>(std::clamp(volume, 0, 100)));
ULONG streamNum = 0;
if (FAILED(g_pVoice->Speak(text.c_str(), SPF_ASYNC, &streamNum))) {
FirePlaybackFailed("InternalError", utteranceID, dest);
return;
}
std::lock_guard<std::mutex> lock(g_streamMx);
g_streamMap[streamNum] = UtteranceMeta{utteranceID, dest, false};
}
void StopAll() {
InitVoice();
if (g_pVoice == nullptr)
return;
g_pVoice->Speak(nullptr, SPF_PURGEBEFORESPEAK, nullptr);
std::lock_guard<std::mutex> lock(g_streamMx);
g_streamMap.clear();
}
// ---------------------------------------------------------------------------
// Lua helpers
// ---------------------------------------------------------------------------
// Push a { {voiceID=, name=}, ... } array built from g_cachedVoices.
void PushVoicesTable(void *L) {
Game::Lua::NewTable(L); // outer array at top
for (size_t i = 0; i < g_cachedVoices.size(); ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1)); // 1-based array key
Game::Lua::NewTable(L); // sub-table
Game::Lua::SetFieldNumber(L, "voiceID", g_cachedVoices[i].voiceID);
Game::Lua::SetFieldString(L, "name",
WideToUtf8(g_cachedVoices[i].name).c_str());
// stack: [outer, key, sub] → outer[key] = sub
Game::Lua::RawSet(L, -3);
}
}
int OptInt(void *L, int idx, int fallback) {
return Game::Lua::IsNumber(L, idx)
? static_cast<int>(Game::Lua::ToNumber(L, idx))
: fallback;
}
// ---------------------------------------------------------------------------
// C_VoiceChat
// ---------------------------------------------------------------------------
int __fastcall Script_GetTtsVoices(void *L) {
RefreshVoices();
PushVoicesTable(L);
return 1;
}
int __fastcall Script_GetRemoteTtsVoices(void *L) {
RefreshVoices();
PushVoicesTable(L);
return 1;
}
int __fastcall Script_SpeakText(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: C_VoiceChat.SpeakText(voiceID, text "
"[, destination, rate, volume])");
return 0;
}
const int voiceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const std::wstring text = Utf8ToWide(Game::Lua::ToString(L, 2));
const int dest = OptInt(L, 3, DEST_LOCAL_PLAYBACK);
const int rate = OptInt(L, 4, 0);
const int volume = OptInt(L, 5, 100);
SpeakText(voiceID, text, dest, rate, volume);
return 0;
}
int __fastcall Script_StopSpeakingText(void *) {
StopAll();
return 0;
}
// ---------------------------------------------------------------------------
// C_TTSSettings
// ---------------------------------------------------------------------------
int __fastcall Script_GetSpeechRate(void *L) {
Game::Lua::PushNumber(L, CVar::Factory::GetInt(g_cvarSpeed, 0));
return 1;
}
int __fastcall Script_GetSpeechVolume(void *L) {
Game::Lua::PushNumber(L, CVar::Factory::GetInt(g_cvarVolume, 100));
return 1;
}
int __fastcall Script_GetSpeechVoiceID(void *L) {
Game::Lua::PushNumber(L, CVar::Factory::GetInt(g_cvarVoice, 0));
return 1;
}
int __fastcall Script_GetVoiceOptionName(void *L) {
const int id = CVar::Factory::GetInt(g_cvarVoice, 0);
auto voices = EnumerateVoices();
if (id >= 0 && id < static_cast<int>(voices.size()))
Game::Lua::PushString(L, WideToUtf8(voices[id].name).c_str());
else
Game::Lua::PushString(L, "");
return 1;
}
int __fastcall Script_SetSpeechRate(void *L) {
if (Game::Lua::IsNumber(L, 1))
CVar::Factory::SetInt(g_cvarSpeed, static_cast<int>(Game::Lua::ToNumber(L, 1)));
return 0;
}
int __fastcall Script_SetSpeechVolume(void *L) {
if (Game::Lua::IsNumber(L, 1))
CVar::Factory::SetInt(g_cvarVolume, static_cast<int>(Game::Lua::ToNumber(L, 1)));
return 0;
}
int __fastcall Script_SetVoiceOption(void *L) {
if (Game::Lua::IsNumber(L, 1))
CVar::Factory::SetInt(g_cvarVoice, static_cast<int>(Game::Lua::ToNumber(L, 1)));
return 0;
}
int __fastcall Script_SetVoiceOptionByName(void *L) {
if (!Game::Lua::IsString(L, 1))
return 0;
const std::wstring want = Utf8ToWide(Game::Lua::ToString(L, 1));
auto voices = EnumerateVoices();
for (const auto &v : voices) {
if (v.name.size() == want.size()) {
bool eq = true;
for (size_t i = 0; i < want.size(); ++i) {
if (towlower(v.name[i]) != towlower(want[i])) {
eq = false;
break;
}
}
if (eq) {
CVar::Factory::SetInt(g_cvarVoice, v.voiceID);
break;
}
}
}
return 0;
}
int __fastcall Script_SetDefaultSettings(void *) {
const int maxVoice = VoiceCount();
CVar::Factory::SetInt(g_cvarVoice, maxVoice > 1 ? 1 : 0);
CVar::Factory::SetInt(g_cvarSpeed, 0);
CVar::Factory::SetInt(g_cvarVolume, 100);
FireVoicesUpdate();
return 0;
}
int __fastcall Script_RefreshVoices(void *) {
RefreshVoices();
return 0;
}
// ---------------------------------------------------------------------------
// CVar clamp callbacks (re-entrant SetString to clamp; reject out-of-range)
// ---------------------------------------------------------------------------
int __fastcall ClampRange(CVar::Factory::Handle cvar, const char *, const char *next,
int lo, int hi) {
if (next == nullptr)
return 1;
const int v = std::atoi(next);
const int c = std::clamp(v, lo, hi);
if (c != v) {
char buf[16];
std::snprintf(buf, sizeof buf, "%d", c);
CVar::Factory::SetString(cvar, buf);
return 0;
}
return 1;
}
int __fastcall OnSpeedChanged(CVar::Factory::Handle c, const char *p, const char *n, void *) {
return ClampRange(c, p, n, -10, 10);
}
int __fastcall OnVolumeChanged(CVar::Factory::Handle c, const char *p, const char *n, void *) {
return ClampRange(c, p, n, 0, 100);
}
int __fastcall OnVoiceChanged(CVar::Factory::Handle c, const char *p, const char *n, void *) {
const int maxIdx = VoiceCount() - 1;
return ClampRange(c, p, n, 0, maxIdx < 0 ? 0 : maxIdx);
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
void RegisterLuaFunctions() {
if (StandaloneTtsPresent())
return; // VanillaTTS owns the TTS surface this session — see above.
// Reserve the events on first (real) registration. AutoReserve only
// appends to Event::Custom's reservation list — the actual slot claims
// happen later via the usual RetryClaims walk — so reserving here instead
// of at static-init time changes nothing but the conditionality.
static const Event::Custom::AutoReserve _r1{kEvtPlaybackFailed};
static const Event::Custom::AutoReserve _r2{kEvtPlaybackFinished};
static const Event::Custom::AutoReserve _r3{kEvtPlaybackStarted};
static const Event::Custom::AutoReserve _r4{kEvtSpeakTextUpdate};
static const Event::Custom::AutoReserve _r5{kEvtVoicesUpdate};
g_cvarVoice = CVar::Factory::Register(kCVarVoice, "0", 0, &OnVoiceChanged);
g_cvarSpeed = CVar::Factory::Register(kCVarSpeed, "0", 0, &OnSpeedChanged);
g_cvarVolume = CVar::Factory::Register(kCVarVolume, "100", 0, &OnVolumeChanged);
Game::Lua::RegisterTableFunction("C_VoiceChat", "GetTtsVoices", &Script_GetTtsVoices);
Game::Lua::RegisterTableFunction("C_VoiceChat", "GetRemoteTtsVoices", &Script_GetRemoteTtsVoices);
Game::Lua::RegisterTableFunction("C_VoiceChat", "SpeakText", &Script_SpeakText);
Game::Lua::RegisterTableFunction("C_VoiceChat", "StopSpeakingText", &Script_StopSpeakingText);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetSpeechRate", &Script_GetSpeechRate);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetSpeechVolume", &Script_GetSpeechVolume);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetSpeechVoiceID", &Script_GetSpeechVoiceID);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetVoiceOptionName", &Script_GetVoiceOptionName);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetDefaultSettings", &Script_SetDefaultSettings);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetSpeechRate", &Script_SetSpeechRate);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetSpeechVolume", &Script_SetSpeechVolume);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetVoiceOption", &Script_SetVoiceOption);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetVoiceOptionByName", &Script_SetVoiceOptionByName);
Game::Lua::RegisterTableFunction("C_TTSSettings", "RefreshVoices", &Script_RefreshVoices);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace VoiceChat::Tts