mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-12 12:57:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aae4eb8a2b | ||
|
|
0a31ee8a89 | ||
|
|
969273f5f7 | ||
|
|
a4760386ed | ||
|
|
f9ef12bc9b | ||
|
|
89d86bffb7 | ||
|
|
a0d8d6d723 | ||
|
|
e0e4fbcf84 | ||
|
|
1da06d4a73 | ||
|
|
960b37d7e6 | ||
|
|
0c27d61839 | ||
|
|
f02f576e70 | ||
|
|
0bd85dd4f3 | ||
|
|
29e3dab24a | ||
|
|
555ea02447 | ||
|
|
e3f5318747 | ||
|
|
cd674aa115 | ||
|
|
f91ffd3189 | ||
|
|
8361ae01eb | ||
|
|
5d785ea0d3 | ||
|
|
e84673dde5 | ||
|
|
a2c5db7b76 |
@@ -44,3 +44,4 @@ Util\Vector3D.lua
|
||||
Util\Vector4D.lua
|
||||
Util\UIParent.lua
|
||||
Util\AuraDurationModifiers.lua
|
||||
Util\SecureUnitMenu.lua
|
||||
|
||||
@@ -43,3 +43,18 @@ end
|
||||
if C_AddOns.DoesAddOnExist("SpecialTalentUI") then
|
||||
EventUtil.ContinueOnAddOnLoaded("SpecialTalentUI", CAPI_ApplyStandardColorGlobals)
|
||||
end
|
||||
|
||||
-- ShaguTweaks libpredict register's TBC events that ClassicAPI backports.
|
||||
if C_AddOns.DoesAddOnExist("ShaguTweaks") then
|
||||
EventUtil.ContinueOnAddOnLoaded("ShaguTweaks", function()
|
||||
local libp = ShaguTweaks.libpredict
|
||||
if libp then
|
||||
libp.sender:UnregisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
|
||||
libp.sender:UnregisterEvent("UNIT_SPELLCAST_START")
|
||||
libp.sender:UnregisterEvent("UNIT_SPELLCAST_STOP")
|
||||
libp.sender:UnregisterEvent("UNIT_SPELLCAST_FAILED")
|
||||
libp.sender:UnregisterEvent("UNIT_SPELLCAST_INTERRUPTED")
|
||||
libp.sender:UnregisterEvent("UNIT_SPELLCAST_SENT")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -26,10 +26,17 @@ local function GenerateClosure(func, ...)
|
||||
for i = 1, boundN do bound[i] = arg[i] end
|
||||
return function(...)
|
||||
local callN = arg.n
|
||||
local merged = {}
|
||||
for i = 1, boundN do merged[i] = bound[i] end
|
||||
for i = 1, callN do merged[boundN + i] = arg[i] end
|
||||
return func(unpack(merged, 1, boundN + callN))
|
||||
-- Forward bound + call args faithfully, embedded/leading nils and all.
|
||||
-- The ONLY thing that makes unpack span holes on this Lua 5.0 build is
|
||||
-- a `.n` field: unpack takes its count from getn (which returns t.n)
|
||||
-- and ignores any explicit i/j, so a `.n`-less table stops dead at the
|
||||
-- first hole (verified: unpack({1, 1, nil, 1}, 1, 4) yields just 1, 1).
|
||||
local all = {}
|
||||
local n = 0
|
||||
for i = 1, boundN do n = n + 1; all[n] = bound[i] end
|
||||
for i = 1, callN do n = n + 1; all[n] = arg[i] end
|
||||
all.n = n
|
||||
return func(unpack(all))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -145,11 +152,14 @@ function CallbackRegistryMixin:TriggerEvent(event, ...)
|
||||
if funcs then
|
||||
for owner, func in pairs(funcs) do
|
||||
-- Stock 3.3.5 passes the owner as the first arg so callers can
|
||||
-- distinguish multiple registrations of the same function.
|
||||
local n = arg.n
|
||||
local merged = { owner }
|
||||
for i = 1, n do merged[i + 1] = arg[i] end
|
||||
func(unpack(merged, 1, n + 1))
|
||||
-- distinguish multiple registrations of the same function. `arg`
|
||||
-- carries its own `.n`, so unpack(arg) forwards the payload with
|
||||
-- leading/embedded nils intact. Do NOT instead merge into a
|
||||
-- `{ owner, ... }` table and unpack that WITHOUT setting `.n`: this
|
||||
-- build's unpack counts via getn, so a `.n`-less table collapses at
|
||||
-- the first hole (verified in-game: reload's PLAYER_ENTERING_WORLD,
|
||||
-- arg1=nil arg2=1, dropped the payload).
|
||||
func(owner, unpack(arg))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
--
|
||||
-- Implementation differences from the modern source:
|
||||
-- - Lua 5.0 has no select() and no `...` as expression; use the
|
||||
-- implicit `arg` table (arg.n + arg[i]) and unpack(arg, 1, arg.n).
|
||||
-- implicit `arg` table (arg.n + arg[i]). unpack(arg) already spans
|
||||
-- arg[1..arg.n] via getn — this build's unpack ignores explicit i/j
|
||||
-- args, so the count must live in the table's `.n`.
|
||||
-- - Lua 5.0 has no `#` operator; use table.getn().
|
||||
-- - 5.0's xpcall is strictly (f, handler) — no trailing args. We wrap
|
||||
-- the script call in a local closure that captures frame + arg.
|
||||
@@ -32,7 +34,7 @@ function ExecuteFrameScript(frame, scriptName, ...)
|
||||
if script then
|
||||
local args = arg;
|
||||
local function invoke()
|
||||
return script(frame, unpack(args, 1, args.n));
|
||||
return script(frame, unpack(args));
|
||||
end
|
||||
xpcall(invoke, CallErrorHandler);
|
||||
end
|
||||
@@ -45,7 +47,7 @@ function CallMethodOnNearestAncestor(self, methodName, ...)
|
||||
end
|
||||
|
||||
if ancestor then
|
||||
return true, ancestor[methodName](ancestor, unpack(arg, 1, arg.n));
|
||||
return true, ancestor[methodName](ancestor, unpack(arg));
|
||||
end
|
||||
|
||||
return false;
|
||||
@@ -58,7 +60,7 @@ function GetValueOrCallFunction(tbl, key, ...)
|
||||
|
||||
local value = tbl[key];
|
||||
if type(value) == "function" then
|
||||
return value(unpack(arg, 1, arg.n));
|
||||
return value(unpack(arg));
|
||||
else
|
||||
return value;
|
||||
end
|
||||
@@ -117,12 +119,12 @@ function IterateTables(iteratorFunction, ...)
|
||||
end
|
||||
|
||||
local s_passThroughClosureGenerators = {
|
||||
function(f) return function(...) return f(unpack(arg, 1, arg.n)); end; end,
|
||||
function(f, a) return function(...) return f(a, unpack(arg, 1, arg.n)); end; end,
|
||||
function(f, a, b) return function(...) return f(a, b, unpack(arg, 1, arg.n)); end; end,
|
||||
function(f, a, b, c) return function(...) return f(a, b, c, unpack(arg, 1, arg.n)); end; end,
|
||||
function(f, a, b, c, d) return function(...) return f(a, b, c, d, unpack(arg, 1, arg.n)); end; end,
|
||||
function(f, a, b, c, d, e) return function(...) return f(a, b, c, d, e, unpack(arg, 1, arg.n)); end; end,
|
||||
function(f) return function(...) return f(unpack(arg)); end; end,
|
||||
function(f, a) return function(...) return f(a, unpack(arg)); end; end,
|
||||
function(f, a, b) return function(...) return f(a, b, unpack(arg)); end; end,
|
||||
function(f, a, b, c) return function(...) return f(a, b, c, unpack(arg)); end; end,
|
||||
function(f, a, b, c, d) return function(...) return f(a, b, c, d, unpack(arg)); end; end,
|
||||
function(f, a, b, c, d, e) return function(...) return f(a, b, c, d, e, unpack(arg)); end; end,
|
||||
};
|
||||
|
||||
local s_flatClosureGenerators = {
|
||||
@@ -138,7 +140,7 @@ local function GenerateClosureInternal(generatorArray, f, ...)
|
||||
local count = arg.n;
|
||||
local generator = generatorArray[count + 1];
|
||||
if generator then
|
||||
return generator(f, unpack(arg, 1, count));
|
||||
return generator(f, unpack(arg));
|
||||
end
|
||||
|
||||
assertsafe(false, "Closure generation does not support more than " .. (table.getn(generatorArray) - 1) .. " parameters");
|
||||
@@ -147,14 +149,14 @@ end
|
||||
|
||||
-- Syntactic sugar for function(...) return f(a, b, c, ...); end
|
||||
function GenerateClosure(f, ...)
|
||||
return GenerateClosureInternal(s_passThroughClosureGenerators, f, unpack(arg, 1, arg.n));
|
||||
return GenerateClosureInternal(s_passThroughClosureGenerators, f, unpack(arg));
|
||||
end
|
||||
|
||||
-- Generates a closure with the specified arguments that will ignore extra arguments when called later. Useful for passing
|
||||
-- through callbacks to APIs where we don't want extra arguments to be passed through, i.e. simple OnClick scripts.
|
||||
-- This is equivalent to: function() return f(a, b, c); end INSTEAD OF function(...) return f(a, b, c, ...); end
|
||||
function GenerateFlatClosure(f, ...)
|
||||
return GenerateClosureInternal(s_flatClosureGenerators, f, unpack(arg, 1, arg.n));
|
||||
return GenerateClosureInternal(s_flatClosureGenerators, f, unpack(arg));
|
||||
end
|
||||
|
||||
function RunNextFrame(callback)
|
||||
@@ -166,7 +168,7 @@ FunctionUtil = {};
|
||||
function FunctionUtil.SafeInvokeMethod(table, methodName, ...)
|
||||
local method = table[methodName];
|
||||
if method then
|
||||
return method(table, unpack(arg, 1, arg.n));
|
||||
return method(table, unpack(arg));
|
||||
end
|
||||
|
||||
return nil;
|
||||
|
||||
@@ -90,3 +90,31 @@ function GameTooltip_ShowCompareItem(self, shift)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Guard the stock GameTooltip UPDATE_MOUSEOVER_UNIT handler (issue #12).
|
||||
--
|
||||
-- ClassicAPI's DLL now fires UPDATE_MOUSEOVER_UNIT on mouseover LOSS too (retail
|
||||
-- parity), not just on gain. The stock inline handler in FrameXML/GameTooltip.xml
|
||||
-- recolors the name unconditionally:
|
||||
-- _G[this:GetName().."TextLeft1"]:SetTextColor(GameTooltip_UnitColor("mouseover"))
|
||||
-- and GameTooltip_UnitColor returns white (1,1,1) when "mouseover" doesn't exist.
|
||||
-- So the loss fire (and the transient clear that happens when clicking a unit)
|
||||
-- repaints the still-visible/fading name white before it disappears -- reported
|
||||
-- as NPC and PvP-flagged-player names flashing white.
|
||||
--
|
||||
-- We wrap the stock handler instead of replacing its body: swallow the event
|
||||
-- only when there is no mouseover unit to color, otherwise delegate to the
|
||||
-- original untouched. During the loss window the name simply keeps its last
|
||||
-- color instead of going white; when a mouseover resolves again it recolors
|
||||
-- normally. (1.12 OnEvent handlers read the `event`/`this` globals the engine
|
||||
-- sets before invoking us, so calling the original as a plain function works.)
|
||||
local originalOnEvent = GameTooltip:GetScript("OnEvent")
|
||||
GameTooltip:SetScript("OnEvent", function()
|
||||
if ( event == "UPDATE_MOUSEOVER_UNIT" and not UnitExists("mouseover") ) then
|
||||
return;
|
||||
end
|
||||
|
||||
if ( originalOnEvent ) then
|
||||
originalOnEvent();
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -22,6 +22,18 @@ function EventRegistry:OnLoad()
|
||||
-- Lua-5.0 varargs, so we forward through a small wrapper.
|
||||
self:DispatchGameEvent(event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)
|
||||
end)
|
||||
|
||||
-- Stop delivering events once logout begins: a callback firing during the
|
||||
-- engine's teardown can touch state that's already gone. A dedicated frame
|
||||
-- owns PLAYER_LOGOUT so this fires regardless of what consumers registered
|
||||
-- — folding the check into frameEventFrame's OnEvent would depend on the
|
||||
-- ref-counted event set still holding PLAYER_LOGOUT at logout time.
|
||||
self.logoutFrame = CreateFrame("Frame")
|
||||
self.logoutFrame:RegisterEvent("PLAYER_LOGOUT")
|
||||
self.logoutFrame:SetScript("OnEvent", function()
|
||||
self.frameEventFrame:UnregisterAllEvents()
|
||||
self.frameEventFrame:SetScript("OnEvent", nil)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Helper so we can forward up to nine event args (the most any vanilla
|
||||
|
||||
@@ -216,8 +216,7 @@ function ItemMixin:GetItemLink() -- requires item data to be loaded
|
||||
end
|
||||
|
||||
if self.itemID then
|
||||
local _, link = GetItemInfo(self.itemID)
|
||||
return link
|
||||
return (select(2, C_Item.GetItemInfo(self.itemID)));
|
||||
end
|
||||
|
||||
if not self:IsItemEmpty() then
|
||||
@@ -299,9 +298,7 @@ end
|
||||
|
||||
function ItemMixin:GetInventoryTypeName()
|
||||
if not self:IsItemEmpty() then
|
||||
-- GetItemInfoInstant returns: itemID, itemType, itemSubType, equipLoc, ...
|
||||
local _, _, _, equipLoc = C_Item.GetItemInfoInstant(self:GetItemID())
|
||||
return equipLoc
|
||||
return select(4, C_Item.GetItemInfoInstant(self:GetItemID()));
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
-- consumer addons doesn't fail; they'll error at Acquire() time
|
||||
-- if anything actually invokes them.
|
||||
-- - Lua 5.0 has no `...` as expression; the converter mixins use
|
||||
-- `unpack(arg, 1, arg.n)` to forward varargs.
|
||||
-- `unpack(arg)` to forward varargs (spans arg[1..arg.n] via getn;
|
||||
-- this build's unpack ignores explicit i/j — the count lives in `.n`).
|
||||
-- - assertsafe / nop aren't globals in stock 1.12; defined as local
|
||||
-- fallbacks (same pattern as FunctionUtil.lua).
|
||||
|
||||
@@ -381,12 +382,12 @@ function FramePoolCollectionConverterMixin:CreatePoolKeyFromPoolArgs(args)
|
||||
end
|
||||
|
||||
function FramePoolCollectionConverterMixin:GetOrCreatePool(...)
|
||||
local args = FramePoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
|
||||
local args = FramePoolCollection_ArgsToTable(unpack(arg));
|
||||
return self:GetOrCreatePoolWithArgs(args);
|
||||
end
|
||||
|
||||
function FramePoolCollectionConverterMixin:CreatePool(...)
|
||||
local args = FramePoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
|
||||
local args = FramePoolCollection_ArgsToTable(unpack(arg));
|
||||
return self:CreatePoolWithArgs(args);
|
||||
end
|
||||
|
||||
@@ -432,12 +433,12 @@ local function FontStringPoolCollection_ArgsToTable(parent, layer, subLayer, tem
|
||||
end
|
||||
|
||||
function FontStringPoolCollectionMixin:GetOrCreatePool(...)
|
||||
local args = FontStringPoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
|
||||
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
|
||||
return self:GetOrCreatePoolWithArgs(args);
|
||||
end
|
||||
|
||||
function FontStringPoolCollectionMixin:CreatePool(...)
|
||||
local args = FontStringPoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
|
||||
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
|
||||
return self:CreatePoolWithArgs(args);
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Unit right-click menu for attribute-driven unit frames.
|
||||
--
|
||||
-- Backs the `menu` / `togglemenu` click verb of the Frame:SetAttribute
|
||||
-- backport (see src/frame/Attributes.cpp). When a frame with a `unit`
|
||||
-- attribute resolves a click to the `menu` verb, the DLL calls
|
||||
-- ClassicAPI_ToggleUnitMenu(unit), which pops the standard unit dropdown at
|
||||
-- the cursor -- the same UnitPopup menu Blizzard's PlayerFrame / TargetFrame /
|
||||
-- PartyMemberFrame show on right-click. Lets a unit frame express its
|
||||
-- right-click menu purely as an attribute (e.g. type2 = "menu") instead of a
|
||||
-- hand-rolled OnClick handler.
|
||||
--
|
||||
-- The unit -> menu-type resolution mirrors Blizzard's own generic resolver
|
||||
-- (TargetFrameDropDown_Initialize in FrameXML): self -> SELF, pet -> PET, a
|
||||
-- grouped player -> PARTY (whisper / inspect / trade / follow / promote / ...),
|
||||
-- any other player -> PLAYER (adds INVITE), anything else (NPC) ->
|
||||
-- RAID_TARGET_ICON. Vanilla's stock UI never wires a unit to the "RAID" menu
|
||||
-- (there are no clickable raid unit frames in 1.12), and PARTY carries the
|
||||
-- options players actually want on a grouped member, so grouped players (party
|
||||
-- or raid) use PARTY -- matching how pfUI drives its raid menu.
|
||||
|
||||
local dropdown;
|
||||
|
||||
local function EnsureDropdown()
|
||||
if not dropdown then
|
||||
dropdown = CreateFrame("Frame", "ClassicAPIUnitMenuDropDown", UIParent, "UIDropDownMenuTemplate");
|
||||
dropdown.displayMode = "MENU";
|
||||
end
|
||||
return dropdown;
|
||||
end
|
||||
|
||||
local function ResolveMenu(unit)
|
||||
if UnitIsUnit(unit, "player") then
|
||||
return "SELF";
|
||||
elseif UnitIsUnit(unit, "pet") then
|
||||
return "PET";
|
||||
elseif UnitIsPlayer(unit) then
|
||||
if UnitInParty(unit) or UnitInRaid(unit) then
|
||||
return "PARTY";
|
||||
end
|
||||
return "PLAYER";
|
||||
end
|
||||
return "RAID_TARGET_ICON";
|
||||
end
|
||||
|
||||
function ClassicAPI_ToggleUnitMenu(unit)
|
||||
if not unit or not UnitExists(unit) then
|
||||
return;
|
||||
end
|
||||
local which = ResolveMenu(unit);
|
||||
local name;
|
||||
if which == "RAID_TARGET_ICON" then
|
||||
name = RAID_TARGET_ICON;
|
||||
end
|
||||
local dd = EnsureDropdown();
|
||||
dd.initialize = function()
|
||||
UnitPopup_ShowMenu(dd, which, unit, name);
|
||||
end
|
||||
ToggleDropDownMenu(1, nil, dd, "cursor");
|
||||
end
|
||||
@@ -431,7 +431,7 @@ function SafePack(...)
|
||||
end
|
||||
|
||||
function SafeUnpack(tbl)
|
||||
return unpack(tbl, 1, tbl.n)
|
||||
return unpack(tbl)
|
||||
end
|
||||
|
||||
-- Returns the length of a table, accounting for the possibility of a table constructed using SafePack.
|
||||
|
||||
@@ -448,8 +448,8 @@ local function CreateFrame_TradeSkillLink()
|
||||
|
||||
prodIcon:SetScript("OnEnter", function()
|
||||
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
|
||||
if this.itemLink then
|
||||
GameTooltip:SetHyperlink(this.itemLink);
|
||||
if this.itemID then
|
||||
GameTooltip:SetHyperlink("item:"..this.itemID);
|
||||
elseif this.spellID then
|
||||
GameTooltip:SetSpellByID(this.spellID);
|
||||
end
|
||||
@@ -490,12 +490,17 @@ local function CreateFrame_TradeSkillLink()
|
||||
nm:SetJustifyV("MIDDLE");
|
||||
btn.nameText = nm;
|
||||
btn:SetScript("OnEnter", function()
|
||||
if this.itemLink then
|
||||
if this.itemID then
|
||||
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
|
||||
GameTooltip:SetHyperlink(this.itemLink);
|
||||
GameTooltip:SetHyperlink("item:"..this.itemID);
|
||||
GameTooltip:Show();
|
||||
end
|
||||
end);
|
||||
btn:SetScript("OnClick", function()
|
||||
if this.itemLink and IsShiftKeyDown() and ChatFrameEditBox:IsVisible() then
|
||||
ChatFrameEditBox:Insert(this.itemLink);
|
||||
end
|
||||
end);
|
||||
btn:SetScript("OnLeave", GameTooltip_Hide);
|
||||
f.reagents[i] = btn;
|
||||
return btn;
|
||||
@@ -564,6 +569,7 @@ local function CreateFrame_TradeSkillLink()
|
||||
local name, tex, link = itemInfo(reagent.itemID);
|
||||
btn:SetNormalTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark");
|
||||
btn.itemLink = link;
|
||||
btn.itemID = reagent.itemID
|
||||
btn.nameText:SetText(name or ("item:" .. reagent.itemID));
|
||||
local need = reagent.count or 1;
|
||||
local have = C_Item.GetItemCount(reagent.itemID) or 0;
|
||||
@@ -582,6 +588,7 @@ local function CreateFrame_TradeSkillLink()
|
||||
end
|
||||
for i = total + 1, table.getn(self.reagents) do
|
||||
self.reagents[i].itemLink = nil;
|
||||
self.reagents[i].itemID = nil;
|
||||
self.reagents[i]:Hide();
|
||||
end
|
||||
|
||||
@@ -630,6 +637,7 @@ local function CreateFrame_TradeSkillLink()
|
||||
self.prodDesc:SetText("");
|
||||
for i = 1, table.getn(self.reagents) do
|
||||
self.reagents[i].itemLink = nil;
|
||||
self.reagents[i].itemID = nil;
|
||||
self.reagents[i]:Hide();
|
||||
end
|
||||
self.detailChild:SetHeight(LO.detail[4]);
|
||||
@@ -726,7 +734,7 @@ local function ShowTradeSkillLink(link, text)
|
||||
local r = recipes[i];
|
||||
if r.isKnown then
|
||||
r.band = DifficultyBand(cur or 0, r.trivialLevel, r.greenLevel);
|
||||
r.name = GetSpellInfo(r.spellID) or ("spell:" .. r.spellID);
|
||||
r.name = C_Spell.GetSpellName(r.spellID) or ("spell:" .. r.spellID);
|
||||
table.insert(knownList, r);
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,7 +35,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
|
||||
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
|
||||
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
|
||||
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha` |
|
||||
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs` |
|
||||
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
|
||||
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
|
||||
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
|
||||
@@ -45,7 +45,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
|
||||
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
|
||||
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemClassInfo`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
|
||||
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
|
||||
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootAllCorpses`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
|
||||
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
|
||||
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
|
||||
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.wipe` |
|
||||
@@ -66,7 +66,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
|
||||
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
|
||||
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
|
||||
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
|
||||
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTimeCached` |
|
||||
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
|
||||
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
|
||||
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
|
||||
@@ -155,6 +155,19 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
|
||||
| `QUEST_REMOVED` | `questID` |
|
||||
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
|
||||
| `UNIT_SPELLCAST_SENT` | `"player", target, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_START` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_STOP` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_DELAYED` | `"player", castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_SUCCEEDED` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_INTERRUPTED` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_FAILED` | `"player", castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_FAILED_QUIET` | `"player", castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_CHANNEL_START` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_CHANNEL_UPDATE` | `"player", castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_CHANNEL_STOP` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_RETICLE_TARGET` | `"player", "", spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_RETICLE_CLEAR` | `"player", "", spellID, spellName, rank` |
|
||||
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
|
||||
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
|
||||
@@ -182,6 +195,7 @@ when launching with `-console`), not as Lua functions. See the
|
||||
|-------|-------------|
|
||||
| `nameplate1`..`nameplateN` | Unit behind the Nth visible nameplate, in creation-order. Works with every `UnitX` function — `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`, etc. Suffix chains (`nameplate1target`, `nameplate1targettarget`) compose. See [NamePlate / Unit tokens](docs/API.md#unit-tokens-nameplaten). |
|
||||
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
|
||||
| `mark1`..`mark8` | Unit currently wearing the Nth raid-target marker (`mark1` = star … `mark8` = skull). Same `UnitX` coverage as `nameplateN`, and fires unit events (`UNIT_HEALTH`, `UNIT_AURA`, …) with `arg1 == "markN"`. `UnitExists("markN")` is `false` when the marker is unset, out of range, or on a non-unit. Suffix chains (`mark1target`) compose. See [Unit tokens (`markN`)](docs/API.md#unit-tokens-markn). |
|
||||
|
||||
### Bindings
|
||||
|
||||
@@ -225,36 +239,38 @@ The Lua-side companion library lives in
|
||||
[`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/). It's a 1.12.1 /
|
||||
Lua 5.0 backport of the modern Blizzard helpers that aren't engine
|
||||
functions but that consumer code still expects to find as globals —
|
||||
`Mixin` / `CreateFromMixins`, `CallbackRegistryMixin`, `EventRegistry`,
|
||||
`ColorMixin` + `CreateColor`, `Item` / `ItemLocation`, `MathUtil`
|
||||
(`Lerp` / `Clamp` / `CreateCounter`), `TableUtil` (`tCompare`,
|
||||
`MergeTable`, `SafePack`, etc.), and `EventUtil`
|
||||
`CallbackRegistryMixin`, `EventRegistry`, `ColorMixin` + `CreateColor`,
|
||||
`Item` / `ItemLocation`, `MathUtil`(`Lerp` / `Clamp` / `CreateCounter`),
|
||||
`TableUtil` (`tCompare`, `MergeTable`, `SafePack`, etc.), and `EventUtil`
|
||||
(`ContinueOnAddOnLoaded` etc.).
|
||||
|
||||
Some of the addon's surface depends on engine functions ClassicAPI
|
||||
provides (`C_UIColor.GetColors` populates the modern color globals,
|
||||
`C_Item.GetItemInfoInstant` powers `ItemMixin`, the
|
||||
`ITEM_DATA_LOAD_RESULT` event drives `ContinueOnItemLoad`); the rest
|
||||
is pure Lua and would work standalone. Without the DLL the addon
|
||||
still loads, but those features no-op.
|
||||
|
||||
The triple-`!` prefix is **load-order-significant** — WoW loads
|
||||
addons alphabetically, so `!!!ClassicAPI` is guaranteed to run before
|
||||
any consumer that depends on its globals. Don't rename the folder.
|
||||
|
||||
**You don't have to install the addon manually** — the DLL embeds
|
||||
the contents of [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) and
|
||||
registers them with the engine as a synthetic addon on startup, so
|
||||
the library is always available when the DLL is loaded. The engine
|
||||
sees it as a normal addon: it appears in `GetAddOnInfo`, fires
|
||||
`ADDON_LOADED`, supports `SavedVariables`, the works.
|
||||
the library is always available when the DLL is loaded. It fires
|
||||
`ADDON_LOADED`, supports `SavedVariables`, and is resolvable by name —
|
||||
`IsAddOnLoaded("!!!ClassicAPI")`, `GetAddOnInfo("!!!ClassicAPI")`, and
|
||||
`## Dependencies: !!!ClassicAPI` all work as usual.
|
||||
|
||||
If you do drop the folder into your `Interface/AddOns/` directory,
|
||||
**that copy wins** — the DLL's embedded version is only registered
|
||||
when the engine's normal scan doesn't already have an entry under
|
||||
that name. Useful for editing the Lua locally without rebuilding the
|
||||
DLL. The dispatch is transparent: addons consuming `Mixin`,
|
||||
`ColorMixin`, `TableUtil`, etc. behave identically in both cases.
|
||||
**It is intentionally always-on and cannot be disabled.** The library
|
||||
provides FrameXML-compat fixes that other addons and the DLL's own
|
||||
features rely on, so leaving it toggleable would let a user (or a
|
||||
one-off character-select uncheck) silently break them. The DLL
|
||||
therefore hides it from the character-select AddOns list and
|
||||
force-enables it every login — so it never appears as a checkbox and
|
||||
can't be switched off. Being hidden means it's omitted from the
|
||||
index-based `GetNumAddOns()` / `GetAddOnInfo(i)` enumeration, but every
|
||||
by-name lookup and dependency reference still resolves normally.
|
||||
|
||||
If you drop the folder into your `Interface/AddOns/` directory, **that
|
||||
copy's files win** — the DLL's embedded version only serves files when
|
||||
the engine's normal scan doesn't already have a newer entry under that
|
||||
name. Useful for editing the Lua locally without rebuilding the DLL.
|
||||
The hidden + force-enabled treatment applies either way (it's keyed to
|
||||
the addon name, not to which copy serves the files), so the local copy
|
||||
is also absent from the AddOns list. The dispatch is transparent:
|
||||
addons consuming `Mixin`, `ColorMixin`, `TableUtil`, etc. behave
|
||||
identically in both cases.
|
||||
|
||||
## Bundled addon: DebugTools
|
||||
|
||||
|
||||
+467
-6
@@ -164,6 +164,8 @@ build instructions.
|
||||
- [`frame:HookScript(scriptType, handler)`](#framehookscriptscripttype-handler)
|
||||
- [`frame:IsEventRegistered(event)`](#frameiseventregisteredevent)
|
||||
- [`frame:GetEffectiveAlpha()`](#framegeteffectivealpha)
|
||||
- [`frame:SetAttribute` / `SetAttributeNoHandler` / `ClearAttribute` / `GetAttribute` (+ unit-frame mouseover)](#framesetattributename-value--framesetattributenohandlername-value--frameclearattributename--framegetattribute)
|
||||
- [`SetModernScriptArgs(enable)` / `GetModernScriptArgs()`](#setmodernscriptargsenable--getmodernscriptargs)
|
||||
|
||||
- [FriendList](#friendlist)
|
||||
- [`C_FriendList.SendWhoQueryByName(name)`](#c_friendlistsendwhoquerybynamename)
|
||||
@@ -294,6 +296,7 @@ build instructions.
|
||||
- [`C_Loot.LootUnit(guid)`](#c_lootlootunitguid)
|
||||
- [`C_Loot.LootUnitItem(guid, itemID)`](#c_lootlootunititemguid-itemid)
|
||||
- [`C_Loot.ScanNearbyLoot()`](#c_lootscannearbyloot)
|
||||
- [`C_Loot.LootAllCorpses([max])`](#c_lootlootallcorpsesmax)
|
||||
- [`C_Loot.IsScanInProgress()`](#c_lootisscaninprogress)
|
||||
- [`C_Loot.GetLastScanResults()`](#c_lootgetlastscanresults)
|
||||
|
||||
@@ -367,6 +370,7 @@ build instructions.
|
||||
- [`C_NamePlate.GetNamePlateForUnit(unitToken)`](#c_nameplategetnameplateforunitunittoken)
|
||||
- [`C_NamePlate.GetNamePlateForGUID(guidString)`](#c_nameplategetnameplateforguidguidstring)
|
||||
- [Unit tokens (`nameplateN`)](#unit-tokens-nameplaten)
|
||||
- [Unit tokens (`markN`)](#unit-tokens-markn)
|
||||
|
||||
- [NameCache](#namecache)
|
||||
- [`GetPlayerInfoByGUID(guid)`](#getplayerinfobyguidguid)
|
||||
@@ -441,6 +445,7 @@ build instructions.
|
||||
- [`C_Spell.CancelSpellByID(spellID)` / `CancelSpellByName(name)`](#c_spellcancelspellbyidspellid--cancelspellbynamename)
|
||||
- [`C_Spell.UnitCastingInfo(unit)` / `C_Spell.CastingInfo()`](#c_spellunitcastinginfounit--c_spellcastinginfo)
|
||||
- [`C_Spell.UnitChannelInfo(unit)` / `C_Spell.ChannelInfo()`](#c_spellunitchannelinfounit--c_spellchannelinfo)
|
||||
- [`UNIT_SPELLCAST_*` events (player)](#unit_spellcast_-events-player)
|
||||
- [`C_Spell.GetSpellLevelInfo(spellID)`](#c_spellgetspelllevelinfospellid)
|
||||
- [`GetSpellRequiredTargetLevel(spellID)`](#getspellrequiredtargetlevelspellid)
|
||||
|
||||
@@ -494,6 +499,7 @@ build instructions.
|
||||
|
||||
- [Time](#time)
|
||||
- [`GetServerTime()`](#getservertime)
|
||||
- [`GetTimeCached()`](#gettimecached)
|
||||
- [`C_Timer.After(seconds, callback)`](#c_timeraftersseconds-callback)
|
||||
- [`C_Timer.NewTimer(seconds, callback)`](#c_timernewtimerseconds-callback)
|
||||
- [`C_Timer.NewTicker(seconds, callback, [iterations])`](#c_timernewtickerseconds-callback-iterations)
|
||||
@@ -2538,9 +2544,15 @@ it: vanilla renders durability UI for equipped gear only.
|
||||
### `HEARTHSTONE_BOUND` event
|
||||
|
||||
Fires (with no payload) every time the player binds their
|
||||
hearthstone at an innkeeper. Polyfills modern WoW's event of the
|
||||
same name — addons listen to `HEARTHSTONE_BOUND` and re-read
|
||||
`GetBindLocation()` to refresh whatever bind-location UI they show.
|
||||
hearthstone at an innkeeper — including a rebind at the same inn.
|
||||
Polyfills modern WoW's event of the same name — addons listen to
|
||||
`HEARTHSTONE_BOUND` and re-read `GetBindLocation()` to refresh
|
||||
whatever bind-location UI they show.
|
||||
|
||||
> Note: the server re-sends the bind packet on map/zone transitions
|
||||
> (behind the loading screen). Those resyncs are suppressed by gating
|
||||
> on the engine's in-world flag, so the event fires only for an actual
|
||||
> innkeeper bind, never on a map change.
|
||||
|
||||
```lua
|
||||
local f = CreateFrame("Frame")
|
||||
@@ -3770,6 +3782,182 @@ UIParent:GetEffectiveAlpha() -- 1
|
||||
Minimap:GetEffectiveAlpha() -- ~0.498 (0.5 truncates to 127/255)
|
||||
```
|
||||
|
||||
### `frame:SetAttribute(name, value)` / `frame:SetAttributeNoHandler(name, value)` / `frame:ClearAttribute(name)` / `frame:GetAttribute(...)`
|
||||
|
||||
Backports the frame **attribute** system — a per-frame, case-insensitive
|
||||
key→value store — to 1.12 as native methods on every frame. Attributes were
|
||||
added in 2.0 with secure frames and don't exist in vanilla at all; `value`
|
||||
can be any Lua type and round-trips exactly.
|
||||
|
||||
`cleared = frame:ClearAttribute(name)` (retail 11.2.0) removes an attribute and
|
||||
returns whether it was set. Unlike `SetAttribute`, it does **not** fire
|
||||
`OnAttributeChanged` — matching retail (verified against a live 12.0 client).
|
||||
|
||||
```lua
|
||||
f:SetAttribute("unit", "party1")
|
||||
f:GetAttribute("unit") -- "party1"
|
||||
f:ClearAttribute("unit") -- true (was set; now removed)
|
||||
f:ClearAttribute("unit") -- false (nothing to clear)
|
||||
f:SetAttribute("count", 3)
|
||||
f:GetAttribute("count") -- 3
|
||||
f:GetAttribute("missing") -- nil
|
||||
```
|
||||
|
||||
`GetAttribute` also has the modifier form `GetAttribute(prefix, name, suffix)`,
|
||||
which tries, in order, and returns the first match (the same precedence retail
|
||||
uses for `type1`/`*type1`-style resolution):
|
||||
|
||||
1. `prefix..name..suffix`
|
||||
2. `"*"..name..suffix`
|
||||
3. `prefix..name.."*"`
|
||||
4. `"*"..name.."*"`
|
||||
5. `name`
|
||||
|
||||
`SetAttributeNoHandler` sets the value **without** firing `OnAttributeChanged`;
|
||||
`SetAttribute` fires it (see below).
|
||||
|
||||
**Unit-frame mouseover — the headline use.** Setting a **string `unit`
|
||||
attribute** makes the frame a mouseover source: while the cursor is over it,
|
||||
the `mouseover` unit token resolves to that frame's unit. This is the piece of
|
||||
SecureUnitButton behavior modern unit-frame addons rely on — and since 1.12 has
|
||||
no combat lockdown or taint, no secure machinery is needed to provide it.
|
||||
|
||||
```lua
|
||||
local f = CreateFrame("Button", "MyUnitFrame", UIParent)
|
||||
f:SetWidth(120); f:SetHeight(40)
|
||||
f:SetPoint("CENTER")
|
||||
f:SetAttribute("unit", "party1")
|
||||
-- Hover it → the `mouseover` token resolves to party1:
|
||||
-- UnitName("mouseover"), UnitHealth("mouseover"), GameTooltip:SetUnit("mouseover"),
|
||||
-- mouseover-cast, and UPDATE_MOUSEOVER_UNIT all work.
|
||||
```
|
||||
|
||||
How it works, and why it's robust: rather than installing `OnEnter`/`OnLeave`
|
||||
on the frame (which an addon's own `SetScript("OnEnter", …)` would overwrite —
|
||||
pfUI sets `unit` *before* its scripts, for instance), this mirrors retail and
|
||||
SuperWoW's `SetMouseoverUnit`. The engine's mouse-focus frame is watched once
|
||||
per frame; when a hovered frame carries a `unit` attribute, the engine's **real
|
||||
mouseover setter** is invoked for that unit — **1:1 with hovering the unit's 3D
|
||||
model**: the model highlights, the mouseover tooltip builds, and
|
||||
`UPDATE_MOUSEOVER_UNIT` fires, in addition to the GUID slot being set. So
|
||||
everything that reads mouseover — the resolver's `mouseover` branch,
|
||||
`GameTooltip:SetUnit("mouseover")`, `UnitX("mouseover")`, mouseover-cast — sees
|
||||
it natively. Because nothing touches the frame's scripts, an addon setting its
|
||||
own handlers can't break it; and it's stomp-proof (the engine's 3D-hover setter
|
||||
is event-driven, so while the cursor is over UI nothing overwrites the slot). It
|
||||
follows live token changes (a `unit="target"` frame tracks your current target
|
||||
while hovered). Setting a string `unit` also `EnableMouse`s the frame so a bare
|
||||
frame becomes hoverable at all (real unit frames already are). The `unit` value
|
||||
may be any token the resolver understands — `"party1"`, `"target"`, `"focus"`,
|
||||
`"nameplateN"`, or a raw GUID literal. Set `unit` to a non-string (e.g. `nil`)
|
||||
to stop the binding.
|
||||
|
||||
**Click actions (`type1` / `type2` / …).** A `type` attribute makes clicking the
|
||||
frame perform one action on its `unit` — the retail secure-button model: exactly
|
||||
**one verb per click**, resolved from the attributes.
|
||||
|
||||
```lua
|
||||
f:SetAttribute("type1", "target") -- left-click targets the unit
|
||||
f:SetAttribute("type2", "focus") -- right-click sets ClassicAPI focus to it
|
||||
-- click-casting, expressed purely as attributes:
|
||||
f:SetAttribute("shift-type1", "spell")
|
||||
f:SetAttribute("shift-spell1", "Flash Heal") -- shift-left-click heals the unit
|
||||
```
|
||||
|
||||
**Resolution.** The verb is read from `[prefix]type[suffix]`, where the prefix is
|
||||
the held modifiers (`alt-`, `ctrl-`, `shift-`, in that order) and the suffix is
|
||||
the button number (`1`=Left, `2`=Right, `3`=Middle, `4`/`5`=side). Precedence is
|
||||
`prefix..type..suffix` → `type..suffix` → `type`, so `type1` applies under any
|
||||
modifier unless a modifier-specific attribute (`shift-type1`) overrides it — and
|
||||
`type` (no suffix) is the catch-all.
|
||||
|
||||
**Verbs.** The resolved verb reads its own extra attributes (also
|
||||
modifier/button-qualified, same precedence as `type`):
|
||||
|
||||
| Verb | Extra attributes | Action |
|
||||
|------|------------------|--------|
|
||||
| `target` | — | Targets the `unit` (or clears the target if `unit` is `"none"`). Respects the engine's default-interaction precedence: with a spell on the cursor it casts on the unit, with an item on the cursor it drops it on the unit, instead of switching target. |
|
||||
| `assist` | — | Targets the `unit`'s target. |
|
||||
| `focus` | — | Sets the ClassicAPI focus to the `unit`. |
|
||||
| `spell` | `spell` | Casts the `spell` on the `unit` via [`C_Spell.CastAtUnit`](#c_spellcastatunitspellidorname-unit) — the unit's GUID goes straight to the cast dispatcher (no target juggling), and ground-target spells land at the unit's feet. |
|
||||
| `item` | `item`, or `bag`+`slot` | Uses an item on the `unit`. `item` may be a name / itemID / link (used via `C_Item.UseItemByName`, unit as the target) or a `"bag slot"` string like `"0 1"` (used via `UseContainerItem`). The deprecated `bag`+`slot` attributes are used when `item` is unset. |
|
||||
| `macro` | `macrotext` or `macro` | Runs the macro text. Prefers an addon `RunMacro` (e.g. SuperCleveRoidMacros — named macros + extended conditionals); otherwise runs the text natively, line by line, through the stock `ChatEdit_ParseText`. |
|
||||
| `stopcasting` | — | Stops the current cast. |
|
||||
| `menu` / `togglemenu` | — | Pops the standard unit dropdown at the cursor (whisper / inspect / trade / invite / …, the same menu `PlayerFrame` / `TargetFrame` / `PartyMemberFrame` show). |
|
||||
|
||||
Every verb that acts on a unit uses the frame's `unit` attribute (resolved with
|
||||
the same modifier/button precedence).
|
||||
|
||||
**One verb per click.** Setting a `type*` attribute installs a **chained
|
||||
`OnClick` on that frame only** (nothing global). When a verb resolves, our
|
||||
handler *owns* the click and does **not** run the frame's previous `OnClick` —
|
||||
so a configured `type1` never fires alongside the addon's own click handler.
|
||||
Unconfigured clicks (no matching `type`, or an unrecognized verb) fall through
|
||||
to the frame's own `OnClick`, so an addon can keep custom behavior there. The
|
||||
frame must be a **Button** registered for the click
|
||||
(left is the Button default; right needs `RegisterForClicks("RightButtonUp")`).
|
||||
|
||||
**Clobbering.** The handler self-heals: addons that re-`SetScript("OnClick", …)`
|
||||
(pfUI on every raid relayout) replace our closure, so re-set a `type*` attribute
|
||||
afterward to reinstall — re-wiring detects and skips its own closure, so it never
|
||||
double-chains.
|
||||
|
||||
**`OnAttributeChanged`** is a real `SetScript` / `GetScript` / `HookScript`-able
|
||||
frame script, on **every** frame — `SetAttribute` fires it after setting the
|
||||
value, `SetAttributeNoHandler` doesn't. As with all 1.12 frame scripts, the
|
||||
handler takes no parameters and reads its context from globals: `this` = the
|
||||
frame, `arg1` = the (lowercased) attribute name, `arg2` = the new value.
|
||||
|
||||
```lua
|
||||
f:SetScript("OnAttributeChanged", function()
|
||||
if arg1 == "unit" then
|
||||
MyFrame_Update(this, arg2) -- react to unit changes
|
||||
end
|
||||
end)
|
||||
f:SetAttribute("unit", "party1") -- fires the handler (arg1="unit", arg2="party1")
|
||||
f:SetAttributeNoHandler("unit", "party2") -- sets it silently, no handler
|
||||
```
|
||||
|
||||
It's implemented the same way as the tooltip-side `OnTooltipSet*` scripts — a
|
||||
co-hook on the base-frame script-name resolver that hands out an external
|
||||
per-frame handler slot for this one name (1.12 frames are never destroyed, so
|
||||
the slot never goes stale). Recursion-guarded, so a handler may itself call
|
||||
`SetAttribute`.
|
||||
|
||||
### `SetModernScriptArgs(enable)` / `GetModernScriptArgs()`
|
||||
|
||||
Global toggle (not a frame method) for **modern positional script arguments**.
|
||||
Vanilla always invokes a frame-script handler with **zero** Lua arguments — the
|
||||
handler reads `this` / `arg1..argN` / `event` as globals. With this enabled, every
|
||||
handler additionally receives its values as real positional arguments, so modern
|
||||
addon ports written as `function(self, delta) … end` work unmodified:
|
||||
|
||||
- most scripts: `(self, arg1..argN)` — e.g. `OnMouseWheel(self, delta)`,
|
||||
`OnClick(self, button)`, `OnValueChanged(self, value)`, `OnUpdate(self, elapsed)`.
|
||||
- `OnEvent`: `(self, event, arg1..argN)`.
|
||||
|
||||
It's purely **additive** — the `this` / `arg1` / `event` globals are still set, so
|
||||
vanilla-style handlers keep working; a handler declaring no parameters just ignores
|
||||
the extras. `SetModernScriptArgs(enable)` sets the state and returns it;
|
||||
`GetModernScriptArgs()` returns the current state.
|
||||
|
||||
**Default OFF (opt-in).** It reimplements the tail of the engine's hottest Lua
|
||||
path (the runner that fires for every `OnUpdate`, every frame), so it stays off
|
||||
unless you ask for it; while off the dispatch path is exactly vanilla. Enable it
|
||||
once at load if your addon relies on the modern handler signature.
|
||||
|
||||
```lua
|
||||
SetModernScriptArgs(true) -- returns true
|
||||
|
||||
local f = CreateFrame("Frame")
|
||||
f:EnableMouseWheel(true)
|
||||
f:SetScript("OnMouseWheel", function(self, delta)
|
||||
-- `self` and `delta` are bound; `this` / `arg1` still work too
|
||||
end)
|
||||
|
||||
GetModernScriptArgs() -- true
|
||||
```
|
||||
|
||||
## FriendList
|
||||
|
||||
### `C_FriendList.SendWhoQueryByName(name)`
|
||||
@@ -7163,6 +7351,45 @@ Per-corpse step timeout is ~3-6 seconds (180 WorldTick frames) so a
|
||||
single dropped server response doesn't hang the whole scan. Failed
|
||||
corpses simply don't appear in the results.
|
||||
|
||||
### `C_Loot.LootAllCorpses([max])`
|
||||
|
||||
Loots **every** nearby lootable corpse in sequence — the same corpse
|
||||
walk as [`ScanNearbyLoot`](#c_lootscannearbyloot), but it actually takes
|
||||
the loot: coin (`CMSG_LOOT_MONEY`) plus every item slot (`CMSG_LOOT_ITEM`)
|
||||
from each window before releasing and advancing to the next corpse. The
|
||||
optional `max` caps how many corpses are visited (default: all in range).
|
||||
|
||||
Returns `true` if the loot walk was queued, `false` if a walk (this or
|
||||
`ScanNearbyLoot`) is already in progress or the world isn't loaded — the
|
||||
two share one state machine, so [`IsScanInProgress`](#c_lootisscaninprogress)
|
||||
reports either, and only one runs at a time.
|
||||
|
||||
Like the scan, the walk is **silent**: `LOOT_OPENED` / `LOOT_CLOSED` are
|
||||
suppressed so `LootFrame` never flickers open per corpse. The normal
|
||||
item-received and money events still fire (`CHAT_MSG_LOOT`,
|
||||
`CHAT_MSG_MONEY`, inventory updates), so the player sees what they got.
|
||||
|
||||
```lua
|
||||
-- Loot everything within interact range
|
||||
C_Loot.LootAllCorpses()
|
||||
|
||||
-- Loot at most 5 corpses
|
||||
C_Loot.LootAllCorpses(5)
|
||||
```
|
||||
|
||||
Completion is signaled by [`LOOT_SCAN_COMPLETED`](#loot_scan_completed-event);
|
||||
afterward [`GetLastScanResults()`](#c_lootgetlastscanresults) reports what
|
||||
each corpse held — i.e. what was looted.
|
||||
|
||||
> **Range and permissions.** Only corpses inside the engine's own
|
||||
> right-click-loot range are queued (same distance test as
|
||||
> `GetNearbyLootableUnits`), and only those the server flagged lootable
|
||||
> for you. The item sends bypass the client-side BoP/unique **confirm
|
||||
> dialog** (appropriate for a programmatic loot-all), but the server still
|
||||
> enforces every real rule — loot rights, distance, master-loot. Unlike a
|
||||
> right-click, this does **not** change your target or auto-walk you toward
|
||||
> out-of-range corpses.
|
||||
|
||||
### `C_Loot.IsScanInProgress()`
|
||||
|
||||
Returns `true` while a `ScanNearbyLoot` call is mid-flight, `false`
|
||||
@@ -8662,6 +8889,74 @@ the unmodified resolver. The ordered list is maintained alongside
|
||||
the existing `NAME_PLATE_UNIT_ADDED` / `_REMOVED` diff in the
|
||||
per-tick nameplate walker.
|
||||
|
||||
### Unit tokens (`markN`)
|
||||
|
||||
`"mark1"` … `"mark8"` work as unit tokens against every `UnitX`
|
||||
function, resolving to whichever unit currently wears that raid-target
|
||||
marker (`mark1` = star, `mark2` = circle, … `mark8` = skull — the same
|
||||
1–8 order as `SetRaidTarget` / `GetRaidTargetIndex`):
|
||||
|
||||
```lua
|
||||
if UnitExists("mark8") then -- something is skull-marked
|
||||
print(UnitName("mark8"), UnitHealth("mark8"))
|
||||
end
|
||||
```
|
||||
|
||||
`UnitExists("markN")` returns `false` when that marker is unset, when the
|
||||
marked unit is out of range / not synced, or when the marker is on a
|
||||
non-unit (a marked corpse, gameobject, or loot). Token chains compose —
|
||||
`"mark1target"`, `"mark1targettarget"` — via the same suffix walker as the
|
||||
other families.
|
||||
|
||||
**Unit events fire for `"markN"`.** `UNIT_HEALTH` / `UNIT_MANA` /
|
||||
`UNIT_AURA` / `UNIT_LEVEL` / … fire with `arg1 == "markN"` when the marked
|
||||
unit's descriptor field changes — vanilla only watched its own
|
||||
target/party/raid units, so a marked mob that isn't otherwise one of those
|
||||
fired nothing:
|
||||
|
||||
```lua
|
||||
local f = CreateFrame("Frame")
|
||||
f:RegisterEvent("UNIT_HEALTH")
|
||||
f:SetScript("OnEvent", function()
|
||||
if arg1 == "mark8" then
|
||||
-- skull's health changed; UnitHealth("mark8") is fresh here
|
||||
end
|
||||
end)
|
||||
```
|
||||
|
||||
Backed by `Unit::TokenObserver` (the same mechanism as `focus` /
|
||||
`nameplateN`). A per-frame watcher diffs the engine's 8-slot marker table
|
||||
and attaches/detaches an observer per marked *unit* — deferring until the
|
||||
unit is in range, since observers can only bind to a live object and a
|
||||
marked unit may be marked while out of range and enter later. The idle
|
||||
case (no markers set) costs a handful of comparisons per frame and no
|
||||
object lookups. A marker on a non-unit is simply never observed.
|
||||
|
||||
### Unit tokens (GUID literals)
|
||||
|
||||
A raw 64-bit GUID literal — `"0x"` followed by up to 16 hex digits
|
||||
(e.g. `"0xF130000000000A5"`) — works as a unit token against the same
|
||||
`UnitX` surface, so a GUID can be passed anywhere a token is expected:
|
||||
|
||||
```lua
|
||||
local guid = UnitGUID("target") -- e.g. "0xF13000..."
|
||||
print(UnitName(guid), UnitHealth(guid), UnitClass(guid))
|
||||
```
|
||||
|
||||
Hex parsing is case-insensitive; the parsed GUID is handed to the engine's
|
||||
own object-manager lookup (the identical path `"player"` takes), so a GUID
|
||||
for a unit that isn't currently loaded (out of range, not synced) resolves
|
||||
to `nil` exactly like any absent unit — no error. Suffix chains compose:
|
||||
`"0x…target"` walks to the referenced unit's target.
|
||||
|
||||
This is the **input** direction only — to obtain a token's GUID use
|
||||
[`UnitGUID`](#unitguidunit).
|
||||
|
||||
**Compatible with SuperWoW.** SuperWoW provides the same GUID-input support,
|
||||
so when it's loaded we detect it and defer to its resolver (no double
|
||||
handling); when it isn't, this fills the gap. Either way GUID-token input
|
||||
behaves identically, so addons needn't care whether SuperWoW is present.
|
||||
|
||||
## NameCache
|
||||
|
||||
GUID-keyed cache of player names and classes. The engine itself
|
||||
@@ -10794,9 +11089,12 @@ so progress is `(GetTime()*1000 - startTimeMs) / (endTimeMs - startTimeMs)`.
|
||||
> the **effective** cast time (the engine's own cast-time helper: base +
|
||||
> level scaling + cast-time talents like Improved Frostbolt + the
|
||||
> haste/cast-speed multiplier), so it matches the in-game cast bar — e.g.
|
||||
> a Mage's talented Frostbolt reads 2.5s, not the 3.0s base. Fields
|
||||
> vanilla can't fill are structurally-correct placeholders:
|
||||
> `castID`/`castBarID` = `nil`, `notInterruptible` = `false`,
|
||||
> a Mage's talented Frostbolt reads 2.5s, not the 3.0s base. `castID` is
|
||||
> the cast's **castGUID** — the exact string the
|
||||
> [`UNIT_SPELLCAST_*`](#unit_spellcast_-events) events carry for this cast,
|
||||
> so you can correlate the polled info with the events (works for the player
|
||||
> and other units). Fields vanilla can't fill are structurally-correct
|
||||
> placeholders: `castBarID` = `nil`, `notInterruptible` = `false`,
|
||||
> `delayTimeMs` = `0`.
|
||||
|
||||
### `C_Spell.UnitChannelInfo(unit)` / `C_Spell.ChannelInfo()`
|
||||
@@ -10828,6 +11126,131 @@ otherwise it falls back to `name`/`displayName`/`textureID`/`spellID` with
|
||||
**`nil` times**. The player path is unchanged (full timing). Same
|
||||
placeholder fields as `C_Spell.UnitCastingInfo`.
|
||||
|
||||
### `UNIT_SPELLCAST_*` events
|
||||
|
||||
Backport of the TBC+ cast/channel events to 1.12, for the **local player and
|
||||
other units**. Ported cast-bar / rotation addons (anything written against
|
||||
the modern signature) register these instead of vanilla's arg-less
|
||||
`SPELLCAST_*` events and read `unit, castGUID, spellID` directly. Thirteen
|
||||
events are provided; six also fire for non-player units:
|
||||
|
||||
| Event | Fires when | Units | Args |
|
||||
|-------|-----------|-------|------|
|
||||
| `UNIT_SPELLCAST_SENT` | `CMSG_CAST_SPELL` leaves the client (earliest point) | player | `unit, target, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_START` | a cast-time spell begins | all | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_STOP` | a cast-time spell ends (any reason) | all | same |
|
||||
| `UNIT_SPELLCAST_DELAYED` | pushback extends the cast | player | same |
|
||||
| `UNIT_SPELLCAST_SUCCEEDED` | the spell goes off (`SMSG_SPELL_GO`) — incl. instants | all | same |
|
||||
| `UNIT_SPELLCAST_INTERRUPTED` | a started **cast** is interrupted (kick, movement, LoS) — never for channels | all | same |
|
||||
| `UNIT_SPELLCAST_FAILED` | a cast is rejected before it starts (range, mana, cooldown) with an error shown | player | same |
|
||||
| `UNIT_SPELLCAST_FAILED_QUIET` | a cast fails with **no** error shown (spammy retry, reticle cancel, …) | player | same |
|
||||
| `UNIT_SPELLCAST_CHANNEL_START` | a channel begins | all | same |
|
||||
| `UNIT_SPELLCAST_CHANNEL_UPDATE` | pushback shortens a channel | player | same |
|
||||
| `UNIT_SPELLCAST_CHANNEL_STOP` | a channel ends | all | same |
|
||||
| `UNIT_SPELLCAST_RETICLE_TARGET` | a ground-target reticle appears (AoE placement — Blizzard, Flare, …) | player | `unit, "", spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_RETICLE_CLEAR` | the reticle is placed or cancelled | player | `unit, "", spellID, spellName, rank` |
|
||||
|
||||
`unit` (arg1) is the token of the casting unit — `"player"` for your own
|
||||
casts, or a unit token (`"target"`, `"focus"`, `"party3"`, `"nameplate2"`,
|
||||
`"pet"`, `"mouseover"`, …) for another unit. `spellName` / `rank` are
|
||||
ClassicAPI tail extensions (modern stops at `spellID`); addons reading only
|
||||
the first three positional args are unaffected.
|
||||
|
||||
**Per-token fan-out.** A caster GUID can map to several tokens at once (your
|
||||
`target` is also `party2` and `nameplate1`). Like retail, the event fires
|
||||
**once per token** currently pointing at the caster, so a `target`-frame
|
||||
cast bar, a `party2` frame, and a nameplate cast bar each get their own
|
||||
event with the same castGUID. Tokens are resolved fresh at fire time (they
|
||||
shift frame-to-frame). If you run **SuperWoW**, its raw-GUID token (`"0x…"`)
|
||||
is deliberately filtered out — only standard tokens are fanned out.
|
||||
|
||||
**Non-player limits.** Only the six events above fire for other units, and
|
||||
they're **best-effort** — driven purely by the packets an observer receives:
|
||||
- `SENT` never fires (only your own outgoing casts are visible).
|
||||
- `DELAYED` / `CHANNEL_UPDATE` never fire (pushback is sent only to the
|
||||
caster, so another unit's bar can't stretch/shrink from damage).
|
||||
- `FAILED` never fires (a pre-cast requirement failure is client-local).
|
||||
- A remote cast/channel only has timing from the moment its
|
||||
`SMSG_SPELL_START` was observed; casters who were already casting when
|
||||
they came into range have no start time.
|
||||
|
||||
**Channels never fire `INTERRUPTED`.** Retail emits only `CHANNEL_STOP` when a
|
||||
channel ends, whether it completed or was cut short (verified against retail),
|
||||
so ClassicAPI matches that for both the player and other units. `INTERRUPTED`
|
||||
is a cast-only event.
|
||||
|
||||
**Reticle events** fire for ground-targeted (AoE) spells only, always for the
|
||||
player: `RETICLE_TARGET` when the placement reticle comes up, `RETICLE_CLEAR`
|
||||
when it's placed or cancelled. There's no cast yet, so the castGUID slot
|
||||
(arg2) is empty — retail pushes `nil` there, but the engine's event
|
||||
dispatcher can't emit a `nil` mid-argument-list, so ClassicAPI pushes `""`
|
||||
instead. `unit` (arg1) and `spellID` (arg3) are exact; arg2 is the only
|
||||
difference and is inconsequential for a reticle.
|
||||
|
||||
**castGUID.** A synthesized string in the modern shape
|
||||
`Cast-<type>-<serverID>-<instanceID>-<zoneUID>-<spellID>-<castUID>`. Vanilla
|
||||
can't know server / instance / zone, so those three fields are `0`; the
|
||||
load-bearing parts are the `spellID` (field 6, which addons `strsplit("-")`
|
||||
out) and a unique-per-cast `castUID` (field 7). **Every event of one cast
|
||||
carries the same castGUID**, so `SENT` → `START`/`CHANNEL_START` →
|
||||
`SUCCEEDED` → `STOP`/`CHANNEL_STOP` all pair up — including across the caster
|
||||
and observers (they converge on the same value), and a chained same-spell
|
||||
recast gets its own castUID. The `type` and `castUID` follow the
|
||||
[spell-cast-GUID spec](https://warcraft.wiki.gg/wiki/GUID#Cast):
|
||||
- **Type 3** (real casts — the common case): `castUID` is time-based — the
|
||||
low 23 bits are the cast's UNIX-epoch second, the higher bits a per-second
|
||||
counter.
|
||||
- **Type 2** (`UNIT_SPELLCAST_FAILED` — a local-only cast that never reached
|
||||
the server): `castUID` is a plain locally-incrementing integer.
|
||||
|
||||
**Ordering** matches modern:
|
||||
|
||||
- Cast-time spell: `SENT → START → SUCCEEDED → STOP`.
|
||||
- Channel: `SENT → CHANNEL_START → SUCCEEDED → CHANNEL_STOP` (CHANNEL_START
|
||||
before SUCCEEDED, as on retail).
|
||||
- Instant: `SENT → SUCCEEDED`.
|
||||
|
||||
**INTERRUPTED vs FAILED vs FAILED_QUIET** follow modern's split: a spell that
|
||||
never started (out of range, not enough mana, on cooldown, LoS to a target)
|
||||
fires `FAILED`, except for a fixed whitelist of "quiet" `SpellCastResult`
|
||||
codes that fire `FAILED_QUIET` instead — `SPELL_IN_PROGRESS` (casting while
|
||||
already casting / a spell-queue rejection), `DONT_REPORT` (fake fails, a
|
||||
cancelled ground reticle), and `CHARMED`. That whitelist mirrors the 3.3.5
|
||||
client's own unit-spellcast dispatch, mapped to vanilla's `SpellCastResult`
|
||||
enum. A spell that was *already casting* and gets stopped (an enemy kick,
|
||||
moving to cancel, breaking LoS mid-cast) fires `INTERRUPTED`. Holding
|
||||
the cast key while running fires `INTERRUPTED` repeatedly (once per retry),
|
||||
each reusing the interrupted cast's castGUID — matching retail.
|
||||
|
||||
**Channel pushback (player).** Taking damage while channeling shortens the
|
||||
channel in vanilla; `CHANNEL_UPDATE` fires on each hit and
|
||||
[`C_Spell.UnitChannelInfo`](#c_spellunitchannelinfounit--c_spellchannelinfo)'s
|
||||
`endTimeMs` re-anchors to the server's new remaining time, so cast bars
|
||||
shrink correctly. (The event carries no time — like retail it's a "re-read
|
||||
now" trigger; timing is read back from `UnitChannelInfo`.)
|
||||
|
||||
Every fire is gated on whether any frame is registered for that event, so
|
||||
the whole system costs one pointer-compare per state transition when no
|
||||
addon uses it (no arg synthesis, no DBC lookups, no per-token fan-out).
|
||||
|
||||
```lua
|
||||
local f = CreateFrame("Frame")
|
||||
for _, e in ipairs({
|
||||
"UNIT_SPELLCAST_START", "UNIT_SPELLCAST_STOP",
|
||||
"UNIT_SPELLCAST_SUCCEEDED", "UNIT_SPELLCAST_CHANNEL_START",
|
||||
}) do f:RegisterEvent(e) end
|
||||
f:SetScript("OnEvent", function()
|
||||
-- vanilla passes event/arg1/... as globals, not function params
|
||||
if arg1 == "target" then print(event, arg3) end -- arg3 = spellID
|
||||
end)
|
||||
```
|
||||
|
||||
> **Additive to the vanilla `SPELLCAST_*` events.** The engine's own arg-less
|
||||
> `SPELLCAST_START` / `SPELLCAST_CHANNEL_UPDATE` / … still fire as before;
|
||||
> these `UNIT_`-prefixed events are the modern layer on top. The empowered-cast
|
||||
> events (`UNIT_SPELLCAST_EMPOWER_*`, a Dragonflight addition) are not
|
||||
> implemented — vanilla has no empowered casts.
|
||||
|
||||
### `C_Spell.GetSpellLevelInfo(spellID)`
|
||||
|
||||
Returns the raw `Spell.dbc` level fields for a spell:
|
||||
@@ -11860,6 +12283,40 @@ right call for calendar / log-timestamp / cooldown-sync use cases.
|
||||
> boundary and the timestamp is accurate to within a second of the
|
||||
> engine's clock for as long as the session continues.
|
||||
|
||||
### `GetTimeCached()`
|
||||
|
||||
Returns the same value as [`GetTime()`](https://warcraft.wiki.gg/wiki/API_GetTime)
|
||||
— seconds on the engine's uptime clock — but **frame-stable**: every call
|
||||
within a single frame returns the identical value, refreshed once per
|
||||
frame. Same epoch as `GetTime()`, so the two are directly comparable.
|
||||
|
||||
Backports the retail 4.x `GetTime()` behavior (retail samples the clock
|
||||
once per frame in the main loop) under a distinct name, leaving vanilla's
|
||||
`GetTime()` untouched — 1.12's `GetTime()` is *live*, recomputing the OS
|
||||
tick on every call, so two same-frame `GetTime()` calls can differ.
|
||||
|
||||
```lua
|
||||
-- Frame-stable: consistent timestamps across a frame's work
|
||||
local now = GetTimeCached()
|
||||
if now - lastFire >= interval then
|
||||
lastFire = now
|
||||
...
|
||||
end
|
||||
```
|
||||
|
||||
> **Not a performance feature.** The underlying tick source is cheap in
|
||||
> 1.12 (`GetTickCount`, a user-mode shared-page read, or `rdtsc`), and
|
||||
> both functions pay the same Lua→C call overhead, which dominates. So
|
||||
> `GetTimeCached()` is not meaningfully faster than `GetTime()` — its
|
||||
> only advantage is the frame-stable semantics. To actually cut cost in a
|
||||
> hot `OnUpdate`, cache the value in a Lua local
|
||||
> (`local now = GetTime()`) and reuse it; that removes the per-call Lua
|
||||
> dispatch, which a cached C function cannot.
|
||||
|
||||
The cache is refreshed from the shared once-per-frame `Tick::WorldTick`
|
||||
hook; before the first world tick (pre-login / glue) it falls back to a
|
||||
live sample, so it never returns 0.
|
||||
|
||||
### `C_Timer.After(seconds, callback)`
|
||||
|
||||
Schedules `callback` to fire once after `seconds`. Returns nothing.
|
||||
@@ -12251,6 +12708,10 @@ local guid = UnitGUID("target") -- "0xF13000059A002553" (the F130... prefix tag
|
||||
local guid = UnitGUID("party1") -- works even if party1 is on a different continent
|
||||
```
|
||||
|
||||
The returned string is itself accepted as a unit token — `UnitName(guid)`,
|
||||
`UnitHealth(guid)`, etc. all work — so a GUID round-trips as a stable
|
||||
handle. See [Unit tokens (GUID literals)](#unit-tokens-guid-literals).
|
||||
|
||||
**Works for OOR party / raid members.** Earlier versions of this
|
||||
function returned nil for `"partyN"` / `"raidN"` when the member's
|
||||
CGObject wasn't currently active in the client (e.g. on another
|
||||
|
||||
@@ -185,6 +185,33 @@ void SetGlobalNumber(void *L, const char *name, double value) {
|
||||
RawSet(L, GLOBALS_INDEX);
|
||||
}
|
||||
|
||||
bool PushGlobalFunction(void *L, const char *name) {
|
||||
PushString(L, name);
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
if (Type(L, -1) == TYPE_FUNCTION)
|
||||
return true;
|
||||
SetTop(L, -2); // pop the non-function
|
||||
return false;
|
||||
}
|
||||
|
||||
void CallGlobal(void *L, const char *name) {
|
||||
const int top = GetTop(L);
|
||||
if (PushGlobalFunction(L, name))
|
||||
Call(L, 0, 0);
|
||||
SetTop(L, top);
|
||||
}
|
||||
|
||||
bool CallGlobalString(void *L, const char *name, const char *arg) {
|
||||
const int top = GetTop(L);
|
||||
const bool called = PushGlobalFunction(L, name);
|
||||
if (called) {
|
||||
PushString(L, arg);
|
||||
Call(L, 1, 0);
|
||||
}
|
||||
SetTop(L, top);
|
||||
return called;
|
||||
}
|
||||
|
||||
void PushLocalizedString(void *L, const char *globalName, const char *fallback) {
|
||||
PushString(L, globalName);
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
|
||||
+16
@@ -244,6 +244,22 @@ void PushLocalizedString(void *L, const char *globalName, const char *fallback);
|
||||
// using `PushLocalizedString` + manual `string.format` invocation.
|
||||
void PushLocalizedFormatInt(void *L, const char *globalName,
|
||||
const char *fallback, int n);
|
||||
|
||||
// Pushes `_G[name]` and returns true only if it resolved to a function
|
||||
// (leaving it on the stack, ready to be called); otherwise pops it and
|
||||
// returns false. The building block for the CallGlobal* helpers below and for
|
||||
// any C module that dispatches to a Lua/FrameXML global by name (a pattern
|
||||
// several modules otherwise open-code).
|
||||
bool PushGlobalFunction(void *L, const char *name);
|
||||
|
||||
// `_G[name]()` — no args, no results; a no-op if the global isn't a function.
|
||||
// Stack-neutral.
|
||||
void CallGlobal(void *L, const char *name);
|
||||
|
||||
// `_G[name](arg)` — one string arg (NULL → nil), no results. Returns true iff
|
||||
// the global was a function and got called, so callers can fall back to a
|
||||
// native path when the FrameXML/addon global is absent. Stack-neutral.
|
||||
bool CallGlobalString(void *L, const char *name, const char *arg);
|
||||
} // namespace Lua
|
||||
|
||||
// Developer-console command system — the `~` console available when the
|
||||
|
||||
+222
-8
@@ -140,6 +140,51 @@ enum Offsets {
|
||||
// and call the inner invoker, which is self-contained.
|
||||
FUN_GAMETOOLTIP_SCRIPT_RESOLVER = 0x005295D0,
|
||||
FUN_FRAME_INVOKE_SCRIPT = 0x00704D50,
|
||||
// The arg-passing sibling of FUN_FRAME_INVOKE_SCRIPT — every frame
|
||||
// script that carries values (OnClick(button), OnMouseWheel(delta),
|
||||
// OnUpdate(elapsed), OnValueChanged(value), OnKeyDown(key), OnEvent's
|
||||
// args, …) funnels through here. __cdecl(int handlerRef, void *frame,
|
||||
// const char *fmt, void *vaPtr): fmt is a printf-style spec string
|
||||
// (`%d`/`%u`/`%f`/`%s`), vaPtr points at the packed vararg buffer
|
||||
// (4-byte stride for d/u/s, 8 for f). It sets the `arg1..argN` globals
|
||||
// from the format (saving/restoring the previous values), sets the
|
||||
// `this` global to `frame`, then runs the handler under a protected
|
||||
// lua_pcall with **zero Lua args** and the engine error handler
|
||||
// (VAR_FRAMESCRIPT_ERROR_HANDLER_REF) as errfunc. FUN_FRAME_INVOKE_SCRIPT
|
||||
// is the same shape for the no-arg scripts (OnShow/OnHide/OnEnter/…).
|
||||
// `Frame::ScriptArgs` co-hooks both to additionally pass the handler
|
||||
// modern positional args (self, arg1..argN). The frame's own Lua object
|
||||
// ref lives at frame+OFF_COBJECT_LUA_REF (refcount at
|
||||
// OFF_COBJECT_LUA_REFCOUNT); FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER
|
||||
// lazily creates it when the refcount is 0.
|
||||
FUN_FRAME_RUN_SCRIPT_ARGS = 0x00704F10,
|
||||
// The base ScriptObject's OnEvent handler slot — an 8-byte
|
||||
// {handler, context} pair at frame+0x0C (from FUN_00702590, the base
|
||||
// resolver every frame-type resolver chains through, and confirmed by
|
||||
// the event dispatchers FUN_00703E50 / FUN_00703F50 passing `frame+0xC`
|
||||
// as the slot). `*(int*)(frame + OFF_FRAME_ONEVENT_SLOT)` is the OnEvent
|
||||
// handler ref; `Frame::ScriptArgs` compares it against the handler ref
|
||||
// the runner is invoking to detect an OnEvent dispatch (exact — each
|
||||
// SetScript makes a distinct ref, so a function bound to two scripts
|
||||
// still differs per slot) and prepend the `event` positional.
|
||||
OFF_FRAME_ONEVENT_SLOT = 0x0C,
|
||||
// Registry ref (an int, not a pointer) to the frame-script message
|
||||
// handler the engine passes as the `errfunc` to every script pcall —
|
||||
// read as `*(int*)VAR_FRAMESCRIPT_ERROR_HANDLER_REF`, pushed via
|
||||
// lua_rawgeti(REGISTRY, ref). Set once at engine init.
|
||||
VAR_FRAMESCRIPT_ERROR_HANDLER_REF = 0x008722C8,
|
||||
// The base Frame script-name resolver — __thiscall(frame, const char *name)
|
||||
// -> int* slot, 0 for an unknown name. Maps the standard base-frame scripts
|
||||
// to their 8-byte {handler, context} slots on the frame (OnLoad@+0x118,
|
||||
// OnUpdate@+0x128, OnEnter@+0x140, … OnKeyUp@+0x190) after delegating to the
|
||||
// ScriptObject base FUN_00702590 (OnEvent@+0xC). Every frame TYPE's resolver
|
||||
// chains through this (the tooltip resolver above calls it first), so
|
||||
// co-hooking it here lets `Frame::Attributes` make `OnAttributeChanged`
|
||||
// SetScript/GetScript/HookScript-able on ALL frames — for that one name the
|
||||
// co-hook hands back an external per-frame cell (frames are immortal in 1.12,
|
||||
// so a pointer-keyed cell never goes stale). Same pattern + ABI modeling as
|
||||
// FUN_GAMETOOLTIP_SCRIPT_RESOLVER.
|
||||
FUN_FRAME_SCRIPT_RESOLVER = 0x0076A0D0,
|
||||
// The other per-object tooltip builders, co-hooked the same way as
|
||||
// FUN_GAMETOOLTIP_BUILD_ITEM to back OnTooltipSetSpell / OnTooltipSetUnit /
|
||||
// OnTooltipSetGameObject (see Tooltip::SetEvents). Each is the single funnel
|
||||
@@ -411,6 +456,12 @@ enum Offsets {
|
||||
// probe — equivalent to checking `this+0x08 > 0` but more direct.
|
||||
OFF_COBJECT_LUA_REFCOUNT = 0x04,
|
||||
|
||||
// `this+0x08` — the CObject's Lua-registry ref (an int key into the
|
||||
// registry table). `lua_rawgeti(REGISTRY, this[+0x08])` pushes the
|
||||
// frame's Lua-side object. Lazily populated by ScriptRegister when the
|
||||
// refcount above is 0.
|
||||
OFF_COBJECT_LUA_REF = 0x08,
|
||||
|
||||
// Direct cvar lookup — `__fastcall(const char *name) → CVar* | NULL`.
|
||||
// Hash-table by-name lookup over the CVar registry; same call
|
||||
// `Script_GetCVar` makes internally before the engine wraps the
|
||||
@@ -554,6 +605,24 @@ enum Offsets {
|
||||
// input it's the right primitive.
|
||||
FUN_TOKEN_TO_GUID = 0x00515970,
|
||||
|
||||
// `__fastcall(const uint64_t *guid /*ecx*/, int *outCount /*edx*/) ->
|
||||
// char** tokenArray` — the engine's GUID → unit-token REVERSE map. Fills
|
||||
// a reused static buffer array (the returned pointer) with the name
|
||||
// string of every ENGINE-NATIVE token currently pointing at `guid` and
|
||||
// writes the count to `*outCount`. Checks, in order: player, pet, target,
|
||||
// party1..4, partypet1..4, raid1..40, raidpet1..40, npc, mouseover — the
|
||||
// same slots the per-token unit-event broadcast `FUN_00515e50` fans out
|
||||
// to (that function is just this map + a fire-per-token loop). Does NOT
|
||||
// know ClassicAPI's synthetic `focus` / `nameplateN` tokens (the engine
|
||||
// has no concept of them) — callers add those separately. Non-throwing:
|
||||
// returns count 0 pre-world (resolves the active player internally and
|
||||
// bails if absent). The returned array is a shared static reused on the
|
||||
// next call, so snapshot the strings before doing anything that could
|
||||
// re-enter. nampower names this `GetNamesFromGUID` (left as an unfilled
|
||||
// TODO in its offsets.hpp). Used by `Unit::Identity::TokensForGUID` for
|
||||
// the phase-2 `UNIT_SPELLCAST_*` remote-unit fan-out.
|
||||
FUN_UNIT_TOKENS_FROM_GUID = 0x00515C50,
|
||||
|
||||
// Mouseover-unit GUID globals — the `"mouseover"` token resolves to
|
||||
// these (see the token-dispatch list above). Written ONLY by
|
||||
// `FUN_SET_MOUSEOVER_UNIT`; zero when nothing is moused over. Used by
|
||||
@@ -1216,6 +1285,20 @@ enum Offsets {
|
||||
// collision, per-channel-start frequency).
|
||||
FUN_SPELL_CHANNEL_START = 0x006E7550,
|
||||
|
||||
// `MSG_CHANNEL_UPDATE` handler (`FUN_006e75f0`) — same `int __stdcall(
|
||||
// uint32_t *opCode, CDataStore *packet)` shape. Body: a single u32 =
|
||||
// the channel's new REMAINING time (ms). Sent to the caster on damage
|
||||
// pushback (which shortens a vanilla channel) and once more at the
|
||||
// channel's end (remaining == 0). Verified at 0x006e75f0: reads the u32,
|
||||
// then fires vanilla's `SPELLCAST_CHANNEL_UPDATE(remaining)` (event
|
||||
// 0x157) on pushback or `SPELLCAST_CHANNEL_STOP` (0x158) at end — but
|
||||
// stores the new end NOWHERE, so `Spell::Cast`'s g_channel.endMs
|
||||
// (computed once at start) never reflects pushback. Co-hooked to
|
||||
// re-anchor endMs (and fire UNIT_SPELLCAST_CHANNEL_UPDATE). nampower
|
||||
// hooks it too (SpellChannelUpdateHandlerHook — accepted co-hook, per-
|
||||
// pushback frequency, same as the START sibling).
|
||||
FUN_SPELL_CHANNEL_UPDATE = 0x006E75F0,
|
||||
|
||||
// `SMSG_SPELL_FAILED_OTHER` handler — `int __stdcall(uint32_t *opCode,
|
||||
// CDataStore *packet)`, same shape as FUN_SPELL_DELAYED. Body:
|
||||
// `casterGuid(u64, plain), spellId(u32)`. In (v)mangos cores this is
|
||||
@@ -1245,6 +1328,21 @@ enum Offsets {
|
||||
// hook it.
|
||||
FUN_SPELL_FAILURE = 0x006E8D80,
|
||||
|
||||
// `Spell_C_SpellFailed` — the CLIENT-side cast-failure entry, distinct
|
||||
// from the two SMSG_SPELL_FAILURE/_OTHER packet handlers above. Fires
|
||||
// for the local player's own cast rejections (out of range, no mana,
|
||||
// "spell not ready", LoS, interrupted, …). `__fastcall(uint32_t
|
||||
// spellId, uint8_t spellResult /*edx*/, int unk1, int unk2, bool
|
||||
// failedByServer)` — signature per nampower's Spell_C_SpellFailedT.
|
||||
// Backs `UNIT_SPELLCAST_FAILED` (`Spell::CastEvents`). Not hooked
|
||||
// elsewhere in this project (we hook the packet handlers, not this).
|
||||
FUN_SPELL_C_SPELL_FAILED = 0x006E1A00,
|
||||
|
||||
// `Spell_C_SpellFailed` result code for a fake/suppressed failure
|
||||
// (Unleashed Potential and similar) — the engine's SPELL_FAILED_DONT_
|
||||
// REPORT. nampower filters it out of its failure event; so do we.
|
||||
SPELL_FAILED_DONT_REPORT = 23,
|
||||
|
||||
// `CGUnit_C::ClearCastingSpell` — `__thiscall void(CGUnit *unit,
|
||||
// int spellID, char notify, char cleanup)` (nampower names the offset
|
||||
// but never hooks it). THE engine choke point for "this unit stopped
|
||||
@@ -1278,9 +1376,29 @@ enum Offsets {
|
||||
// funnels through here (the raw-ingress sibling at 0x00537B10 only
|
||||
// special-cases 0x1DD compressed-moves before queueing into this).
|
||||
// Useful as a temporary whole-protocol sniff point: co-hook, peek the
|
||||
// u16 at the cursor, restore, log.
|
||||
// u16 at the cursor, restore, log. `Net::PacketDispatch` co-hooks it as
|
||||
// the single fan-out point for incoming-packet subscribers — the
|
||||
// collision-avoiding alternative to each feature MinHook-ing its
|
||||
// per-opcode SMSG handler (which nampower/SuperWoW also hook). nampower
|
||||
// does NOT hook this funnel (it detours the per-opcode handlers), so
|
||||
// moving our parsing here removes those shared prologues.
|
||||
FUN_NET_MESSAGE_DISPATCH = 0x00537AA0,
|
||||
|
||||
// Incoming spell-subsystem SMSG opcodes — the u16 message ids the engine
|
||||
// dispatch table (see FUN_006e7150) maps to per-opcode handlers. Used by
|
||||
// the `Net::PacketDispatch` subscribers that replaced the per-handler
|
||||
// co-hooks. Verified from the registrar: 0x131/0x132 both route to
|
||||
// FUN_006E7640 (it branches on the opcode; the 0x132 path decodes and
|
||||
// calls the SpellGo builder FUN_006E7A70).
|
||||
SMSG_SPELL_START = 0x131,
|
||||
SMSG_SPELL_GO = 0x132,
|
||||
SMSG_SPELL_FAILURE = 0x133,
|
||||
SMSG_SPELL_COOLDOWN = 0x134,
|
||||
SMSG_SPELL_CHANNEL_START = 0x139,
|
||||
SMSG_SPELL_CHANNEL_UPDATE = 0x13A,
|
||||
SMSG_SPELL_DELAYED = 0x1E2,
|
||||
SMSG_SPELL_FAILED_OTHER = 0x2A6,
|
||||
|
||||
// NetClient send — `__thiscall void(void *conn, CDataStore *packet)`.
|
||||
// The outgoing counterpart of FUN_NET_MESSAGE_DISPATCH: every CMSG the
|
||||
// client sends funnels through it (the argless wrapper `FUN_005AB630`,
|
||||
@@ -1375,12 +1493,27 @@ enum Offsets {
|
||||
// clear on `*(context+0xCFC) == self`, i.e. the IsDragging predicate.
|
||||
VAR_UI_CONTEXT_PTR = 0x00CF0BD8,
|
||||
OFF_UI_CONTEXT_DRAG_TARGET = 0xCFC,
|
||||
// Frame currently under the mouse (the CFrameScriptObject* that
|
||||
// `GetMouseFocus` returns): `*(*VAR_UI_CONTEXT_PTR + 0x7C)`. 0 when the
|
||||
// cursor is over no mouse-enabled frame. `Frame::Attributes` polls this to
|
||||
// drive the `mouseover` override from the hovered frame's `unit` attribute.
|
||||
OFF_UI_CONTEXT_MOUSE_FOCUS = 0x7C,
|
||||
FUN_SCRIPT_FRAME_SHOW = 0x00775750,
|
||||
FUN_SCRIPT_FRAME_HIDE = 0x00775810,
|
||||
FUN_SCRIPT_FRAME_SETMINRESIZE = 0x00776020,
|
||||
FUN_SCRIPT_FRAME_SETMAXRESIZE = 0x007762A0,
|
||||
FUN_SCRIPT_FRAME_GETSCRIPT = 0x00774780,
|
||||
FUN_SCRIPT_FRAME_SETSCRIPT = 0x007748D0,
|
||||
// `frame:EnableMouse(enable)` (Frame registry) — `Frame::Attributes` calls
|
||||
// it so a unit-attributed frame registers as the mouse-focus (bare frames
|
||||
// otherwise never hover). Standard `int __fastcall(void *L)` Script_* shape.
|
||||
FUN_SCRIPT_FRAME_ENABLEMOUSE = 0x00777070,
|
||||
// Button OnClick dispatcher — `__thiscall(button, buttonCode)` at
|
||||
// 0x00779540, invoking the button's OnClick slot `[button+0x4CC]`. NOTE: do
|
||||
// NOT MinHook it — SuperWoW's click-casting inline-hooks the same prologue
|
||||
// and a second hook corrupts the trampoline (ERROR #132). `Frame::Attributes`
|
||||
// instead installs a normal chained OnClick on the opted-in frame. Kept as
|
||||
// the verified dispatch reference only.
|
||||
// Frame GetAlpha (own alpha, 0..1) + Region GetParent — walked by
|
||||
// Frame::Modern's GetEffectiveAlpha up the parent chain.
|
||||
FUN_SCRIPT_FRAME_GETALPHA = 0x00774DC0,
|
||||
@@ -3518,6 +3651,23 @@ enum Offsets {
|
||||
// `FUN_006E3D10`. Non-zero means "the cast bar is showing this
|
||||
// spell"; cleared to 0 when the cast finishes or is cancelled.
|
||||
VAR_CURRENT_CAST_SPELL = 0x00CECA88,
|
||||
|
||||
// Ground/reticle-targeting flags — non-zero while a spell is awaiting a
|
||||
// target click (the AoE reticle / target cursor). `SpellIsTargeting()`
|
||||
// (`0x006E6CD0`) returns true iff this != 0 (via its predicate
|
||||
// `FUN_006E48A0`, `return DAT_00CECAC0 != 0`); `Spell_C_TargetSpell`
|
||||
// (`0x006E5250`) sets the flag bits from the spell's target type, and the
|
||||
// clear helper `FUN_006E4900` (from `SpellStopTargeting`) / a ground
|
||||
// placement zeroes it. Polled by `Spell::CastEvents::PollReticle` to fire
|
||||
// UNIT_SPELLCAST_RETICLE_TARGET / _CLEAR.
|
||||
VAR_SPELL_TARGETING_FLAGS = 0x00CECAC0,
|
||||
// The pending cast spellID — written by `Spell_C_CastSpell` (`0x006E4B60`)
|
||||
// to the spell being cast, and used elsewhere as a `Spell.dbc` index (e.g.
|
||||
// `CancelSpell`'s `records[this]`). While `VAR_SPELL_TARGETING_FLAGS` is
|
||||
// set it's the spell whose reticle is up, so the RETICLE events read the
|
||||
// spellID from here.
|
||||
VAR_PENDING_CAST_SPELL = 0x00CEAC58,
|
||||
|
||||
// The cast-start state writer — `__fastcall(int spellID, int
|
||||
// targetState)`. Sets `VAR_CURRENT_CAST_SPELL` (pushing any current
|
||||
// spell into `VAR_QUEUED_CAST_SPELL`; `spellID == 0` restores the queued
|
||||
@@ -3825,6 +3975,19 @@ enum Offsets {
|
||||
// `Script_IsInInstance`).
|
||||
VAR_CURRENT_MAP_ID = 0x00B4E378,
|
||||
|
||||
// "World is live" flag — 1 while the player is fully in the world, 0 during
|
||||
// any loading screen (initial login, zone/instance transition, logout to
|
||||
// glue). Set to 1 by the enter-world init `FUN_004908C0` (guarded
|
||||
// `if (==0){=1;...}`) and back to 0 by the world-teardown `FUN_00490A80`
|
||||
// (`if(!=0){=0;...}`) that runs at the start of every transition. Byte flag.
|
||||
// The clean pollable gate for "can this happen in the world right now" —
|
||||
// e.g. suppressing server resync packets that arrive behind a loading
|
||||
// screen. Verified via the enter/leave pair in Ghidra.
|
||||
//
|
||||
// (NOTE: 0x00B4E37C is NOT this flag — an earlier note wrongly claimed it
|
||||
// was. That address is instance grace-period state, read only by the
|
||||
// "recently left an instance" 90-second check in `FUN_00495c90`.)
|
||||
VAR_IN_WORLD = 0x00B4B424,
|
||||
|
||||
// Local player's current AreaTable.dbc area ID (u32 storage; only
|
||||
// the low 16 bits are used — the value is broadcast over the wire
|
||||
@@ -3929,6 +4092,17 @@ enum Offsets {
|
||||
VAR_PARTY_MEMBER_GUIDS = 0x00BC6F48,
|
||||
VAR_RAID_MEMBER_PTRS = 0x00B712A8,
|
||||
|
||||
// Raid-target-marker GUID table — 8 contiguous `uint64` slots, one
|
||||
// per raid icon (slot 0 = mark1/star … slot 7 = mark8/skull), `0`
|
||||
// when that marker is unassigned. Written by the
|
||||
// `SMSG_RAID_TARGET_UPDATE` handler cluster (`FUN_004ba0b0` /
|
||||
// `FUN_004ba220` / `FUN_004ba550`); the reverse lookup
|
||||
// `FUN_004bb190(guidLo, guidHi)` walks it as `(&table)[i*2]` for
|
||||
// `i` in 0..7 and returns the mark index or 8. `Unit::RaidTarget`
|
||||
// reads it to resolve the `markN` token and to drive the per-mark
|
||||
// unit-event observers. Matches nampower's `CGRaidInfo_m_raidtargets`.
|
||||
VAR_RAID_TARGET_GUIDS = 0x00B71368,
|
||||
|
||||
VAR_LOCALE_INDEX = 0x00C0E080, // 0..8, picks one of the 9 localized strings
|
||||
|
||||
LUA_IS_NUMBER = 0x6F34D0,
|
||||
@@ -3977,6 +4151,15 @@ enum Offsets {
|
||||
LUA_NEW_TABLE = 0x6F3C90,
|
||||
LUA_GET_TABLE = 0x6F3A40, // (was 0x6F3EA0, which is lua_rawset)
|
||||
LUA_RAW_GET = 0x6F3B00,
|
||||
// `lua_rawgeti(L, idx, n)` — pushes `table_at_idx[n]` without invoking
|
||||
// metamethods. __fastcall(L /*ecx*/, idx /*edx*/, n /*stack*/). The
|
||||
// frame-script runner uses it with `idx = REGISTRY` to push a value
|
||||
// stored under an integer ref (handler, frame object, message handler,
|
||||
// and luaL_ref'd saved globals). Note it also carries the WoW
|
||||
// frame-script exec-context stamp (the `DAT_00ceeac0` dance) for pushed
|
||||
// CObjects — which is exactly why the engine pushes handler/frame
|
||||
// through it rather than a plain rawget.
|
||||
LUA_RAWGETI = 0x6F3BC0,
|
||||
LUA_SET_TABLE = 0x6F3E20,
|
||||
LUA_RAW_SET = 0x6F3EA0,
|
||||
LUA_INSERT = 0x6F31A0,
|
||||
@@ -4635,6 +4818,22 @@ enum Offsets {
|
||||
FUN_ADDON_CAN_LOAD = 0x0051E780,
|
||||
FUN_ADDON_GET_SECURITY_INDEX = 0x0051E990,
|
||||
|
||||
// Per-character enable-state resolver — the inner check
|
||||
// `FUN_ADDON_CAN_LOAD` (`0x0051E780`) consults to decide whether an
|
||||
// addon is enabled: `__fastcall(const char *name /*ecx*/,
|
||||
// const char *account /*edx*/, char resolveAll /*stack*/) -> uint`.
|
||||
// Returns 0 = disabled (load pass then skips the addon, reason
|
||||
// DISABLED), non-zero = enabled (2 = "enabled for all"). Walks the
|
||||
// per-character override list at `0x00BE1BD8`; with no override it
|
||||
// falls back to the entry's DefaultState byte (`+0x2b`):
|
||||
// `-(uint)(entry[+0x2b] != 0) & 2`. `Addons::Embedded` co-hooks this
|
||||
// to force-enable the embedded `!!!ClassicAPI` unconditionally — so a
|
||||
// stale WTF disable-override (from back when the addon was still
|
||||
// shown in the character-select list) can't leave it hidden AND
|
||||
// unloaded. Cold: runs only during the login/reload load pass, once
|
||||
// per addon.
|
||||
FUN_ADDON_ENABLE_RESOLVE = 0x0051E470,
|
||||
|
||||
// Returns a pointer to the inline-stored login account name buffer
|
||||
// at `0x00C27D88`, or NULL if no character is logged in yet.
|
||||
// `Script_GetAddOnInfo` feeds this into the loadable + enabled
|
||||
@@ -4875,18 +5074,19 @@ enum Offsets {
|
||||
//
|
||||
// Single caller (the packet dispatcher's jump table for opcode
|
||||
// 0x155) — quiet hook target, no contention with other DLLs.
|
||||
// Hooked by `Player::HearthstoneBound` to fire `HEARTHSTONE_BOUND`
|
||||
// every time the user re-binds at an innkeeper. Gating off the
|
||||
// bind-valid flag's transition distinguishes "initial sync" (the
|
||||
// one we suppress) from "user rebound at an inn" (the one we
|
||||
// fire on — even when the area string stays the same, since the
|
||||
// bind ACTION is what fires the event).
|
||||
// Hooked by `Player::HearthstoneBound` to fire `HEARTHSTONE_BOUND`.
|
||||
// Two packets must be suppressed so the event reflects only a real
|
||||
// innkeeper bind: (1) the initial post-login/char-switch sync
|
||||
// (gated on VAR_BIND_POINT_VALID being 0 before the handler runs),
|
||||
// and (2) Turtle's per-map-transition resync, which re-sends the
|
||||
// packet behind a loading screen — gated on VAR_IN_WORLD (the
|
||||
// player can't bind mid-load). See `Player::HearthstoneBound`.
|
||||
FUN_BINDPOINT_UPDATE_HANDLER = 0x005ED3C0,
|
||||
// "Bind point is valid" flag. Set to 1 by the BINDPOINTUPDATE
|
||||
// handler after the packet has been fully parsed. Zeroed by
|
||||
// `FUN_005E2510` (the per-character-entry init routine).
|
||||
// Reading this BEFORE the handler runs distinguishes initial
|
||||
// sync (`== 0`) from a real re-bind (`== 1`).
|
||||
// sync (`== 0`) from a later packet (`== 1`).
|
||||
VAR_BIND_POINT_VALID = 0x00C4D4E0,
|
||||
|
||||
// SMSG_QUESTGIVER_QUEST_COMPLETE handler (opcode 0x191). Server
|
||||
@@ -5100,6 +5300,20 @@ enum Offsets {
|
||||
// delayed crashes on unrelated vtable reads.
|
||||
FUN_CMSG_LOOT_ITEM = 0x005E1AD0,
|
||||
|
||||
// `__fastcall(player)` — sends `CMSG_LOOT_MONEY` (the coin/money
|
||||
// request) for the player's currently-open loot window. Vanilla has
|
||||
// no `LootMoney` Lua global; coin is virtual loot slot 0 and the Lua
|
||||
// `LootSlot` handler `FUN_004C2790` routes the coin slot here (its
|
||||
// `param_1 == 0 && VAR_LOOT_LOOTABLE != 0` branch calls this with the
|
||||
// resolved local player). Guards internally on the player's active
|
||||
// loot GUID at `[player + 0x1D28]` — which `FUN_CMSG_LOOT_UNIT` writes
|
||||
// when it sends the loot request — so it silently no-ops if no window
|
||||
// is open. Pass the SAME player pointer used for `FUN_CMSG_LOOT_UNIT`
|
||||
// so the `+0x1D28` guard it just wrote is the one read here.
|
||||
// `Loot::Scan`'s `LootAllCorpses` path uses it to grab coin alongside
|
||||
// the per-slot `CMSG_LOOT_ITEM` sends.
|
||||
FUN_CMSG_LOOT_MONEY = 0x005EA8C0,
|
||||
|
||||
// UNIT_DYNAMIC_FLAGS within `m_objectFields`. Standard
|
||||
// server-driven dynamic-state bitmask. Empirically verified by
|
||||
// cross-referencing three `Script_*` accessors that all read
|
||||
|
||||
+54
-2
@@ -336,7 +336,32 @@ using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
|
||||
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
|
||||
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
|
||||
|
||||
void MoveEmbeddedToListHead() {
|
||||
// `entry+0x29` — the "filter-out" byte. The flat display-array builder
|
||||
// `FUN_0051da70` walks the linked list and copies an entry into
|
||||
// `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when this byte is 0, so
|
||||
// setting it hides `!!!ClassicAPI` from the character-select AddOns
|
||||
// list — there's no checkbox, so it can't be toggled off. The builder
|
||||
// only ever *writes* this byte on the secure/SMSG path (`entry+0x28 !=
|
||||
// 0`); our entry is non-secure, so a manual `1` here is stable across
|
||||
// rebuilds. Hiding does NOT stop loading (the load pass `FUN_0051f600`
|
||||
// never reads `+0x29`) and does NOT break dependency resolution or
|
||||
// by-name queries (those use the addon hash table, which keeps the
|
||||
// entry regardless).
|
||||
constexpr int OFF_ADDON_ENTRY_FILTER_OUT = 0x29;
|
||||
|
||||
// `entry+0x2b` — DefaultState (`## DefaultState: enabled/disabled`).
|
||||
// The enable resolver `FUN_ADDON_ENABLE_RESOLVE` falls back to this
|
||||
// when the player has no per-character override. Belt-and-suspenders
|
||||
// alongside the resolver co-hook below (which forces enabled anyway).
|
||||
constexpr int OFF_ADDON_ENTRY_DEFAULT_STATE = 0x2b;
|
||||
|
||||
// Post-registration finalize for the embedded entry: move it to the
|
||||
// head of the load-order list (so consumers that read our globals from
|
||||
// their main chunk see them — see the block comment above the offsets),
|
||||
// hide it from the addon list, and mark it default-enabled. Combined
|
||||
// with the `FUN_ADDON_ENABLE_RESOLVE` co-hook, `!!!ClassicAPI` always
|
||||
// loads and never appears as a toggleable entry.
|
||||
void FinalizeEmbeddedEntry() {
|
||||
uintptr_t entry = *reinterpret_cast<uintptr_t *>(VAR_ADDON_LIST_HEAD);
|
||||
const int linkOffset = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
|
||||
@@ -344,6 +369,11 @@ void MoveEmbeddedToListHead() {
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
entry + OFF_ADDON_ENTRY_NAME_PTR);
|
||||
if (name != nullptr && std::strcmp(name, kAddonName) == 0) {
|
||||
// Hide from the character-select AddOns list and default it
|
||||
// to enabled.
|
||||
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_FILTER_OUT) = 1;
|
||||
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
|
||||
|
||||
auto fn = reinterpret_cast<ListInsert_t>(
|
||||
Offsets::FUN_INTRUSIVE_LIST_INSERT);
|
||||
fn(reinterpret_cast<void *>(
|
||||
@@ -361,7 +391,24 @@ void __fastcall AddonInit_h(const char *accountName) {
|
||||
AddonInit_o(accountName);
|
||||
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
|
||||
TocParser(kAddonName);
|
||||
MoveEmbeddedToListHead();
|
||||
FinalizeEmbeddedEntry();
|
||||
}
|
||||
|
||||
// `FUN_ADDON_ENABLE_RESOLVE` — per-character enable resolver. We
|
||||
// co-hook it to force-enable `!!!ClassicAPI` regardless of DefaultState
|
||||
// or any stale WTF disable-override, so hiding it from the addon list
|
||||
// can never leave it stuck off. Returns 2 ("enabled for all"); the
|
||||
// load-pass gate only tests `!= 0`. Every other addon falls through to
|
||||
// the original untouched. See the Offsets.h note for the ABI.
|
||||
using AddonEnableResolve_t =
|
||||
uint32_t(__fastcall *)(const char *name, const char *account, char resolveAll);
|
||||
AddonEnableResolve_t AddonEnableResolve_o = nullptr;
|
||||
|
||||
uint32_t __fastcall AddonEnableResolve_h(const char *name, const char *account,
|
||||
char resolveAll) {
|
||||
if (name != nullptr && PathEqualsCI(name, kAddonName))
|
||||
return 2;
|
||||
return AddonEnableResolve_o(name, account, resolveAll);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookFileRead{
|
||||
@@ -374,6 +421,11 @@ const Game::HookAutoRegister _hookAddonInit{
|
||||
reinterpret_cast<void *>(&AddonInit_h),
|
||||
reinterpret_cast<void **>(&AddonInit_o)};
|
||||
|
||||
const Game::HookAutoRegister _hookEnableResolve{
|
||||
Offsets::FUN_ADDON_ENABLE_RESOLVE,
|
||||
reinterpret_cast<void *>(&AddonEnableResolve_h),
|
||||
reinterpret_cast<void **>(&AddonEnableResolve_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Addons::Embedded
|
||||
|
||||
+10
-20
@@ -21,6 +21,7 @@
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "net/PacketReader.h"
|
||||
#include "net/SendObserver.h"
|
||||
#include "spell/Mod.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
@@ -86,28 +87,17 @@ int ConsumeCapturedCP(uint32_t spellId) {
|
||||
// re-entry, but still build and send CMSG_CAST_SPELL through the engine).
|
||||
// Snapshot the combo points at the moment the cast request leaves: the
|
||||
// server consumes them before SMSG_SPELL_GO arrives, so reading at
|
||||
// SpellGo time is too late.
|
||||
using NetSend_t = void(__fastcall *)(void *conn, void *edx,
|
||||
CDataStore *packet);
|
||||
NetSend_t g_origNetSend = nullptr;
|
||||
|
||||
void __fastcall NetSend_h(void *conn, void *edx, CDataStore *packet) {
|
||||
if (packet != nullptr) {
|
||||
const uint32_t saved = packet->m_read;
|
||||
const uint32_t opcode = Net::Read<uint32_t>(packet);
|
||||
if (opcode == Offsets::OP_CMSG_CAST_SPELL) {
|
||||
const uint32_t spellId = Net::Read<uint32_t>(packet);
|
||||
if (spellId != 0)
|
||||
g_capture = {spellId, CurrentComboPoints(), NowMs()};
|
||||
}
|
||||
packet->m_read = saved;
|
||||
}
|
||||
g_origNetSend(conn, edx, packet);
|
||||
// SpellGo time is too late. We watch sends through the shared
|
||||
// `Net::SendObserver` rather than owning the funnel hook ourselves.
|
||||
void OnSend(uint32_t opcode, Net::CDataStore *packet) {
|
||||
if (opcode != Offsets::OP_CMSG_CAST_SPELL)
|
||||
return;
|
||||
const uint32_t spellId = Net::Read<uint32_t>(packet);
|
||||
if (spellId != 0)
|
||||
g_capture = {spellId, CurrentComboPoints(), NowMs()};
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _sendHook{
|
||||
Offsets::FUN_NET_SEND, reinterpret_cast<void *>(&NetSend_h),
|
||||
reinterpret_cast<void **>(&g_origNetSend)};
|
||||
const Net::SendObserver::AutoSubscribe _sendSub{&OnSend};
|
||||
|
||||
// ---- Duration data (SpellDuration.dbc + Lua overrides) --------------------
|
||||
|
||||
|
||||
+47
-30
@@ -19,8 +19,10 @@
|
||||
#include "Data.h"
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "net/PacketDispatch.h"
|
||||
#include "net/PacketReader.h"
|
||||
#include "player/StatSignal.h"
|
||||
#include "spell/CastEvents.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "tick/WorldTick.h"
|
||||
#include "totem/Tracker.h"
|
||||
@@ -554,37 +556,28 @@ const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
// keeps the parse short and robust.
|
||||
constexpr int kMaxTargets = 16;
|
||||
|
||||
using SpellGo_t = void(__fastcall *)(uint64_t *itemGUID, uint64_t *casterGUID,
|
||||
uint32_t spellId, CDataStore *packet);
|
||||
SpellGo_t g_origSpellGo = nullptr;
|
||||
|
||||
void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
|
||||
uint32_t spellId, CDataStore *packet) {
|
||||
uint64_t targets[kMaxTargets];
|
||||
int numTargets = 0;
|
||||
|
||||
if (packet != nullptr) {
|
||||
const uint32_t savedRead = packet->m_read;
|
||||
Net::Read<int16_t>(packet); // castFlags (unused)
|
||||
const uint8_t numHit = Net::Read<uint8_t>(packet);
|
||||
for (int i = 0; i < numHit; ++i) {
|
||||
const uint64_t guid = Net::Read<uint64_t>(packet);
|
||||
if (numTargets < kMaxTargets)
|
||||
targets[numTargets++] = guid;
|
||||
}
|
||||
packet->m_read = savedRead; // restore so the engine re-parses cleanly
|
||||
}
|
||||
|
||||
g_origSpellGo(itemGUID, casterGUID, spellId, packet);
|
||||
|
||||
const uint64_t caster = (casterGUID != nullptr) ? *casterGUID : 0;
|
||||
// The self-contained SPELL_GO processing (succeeded events, server-side
|
||||
// duration edits, aura-source caching). None of it reads engine descriptor
|
||||
// state, so it's order-independent of the engine's own SPELL_GO handler —
|
||||
// which is why it can run from the dispatch funnel (before the leaf handler)
|
||||
// rather than after a co-hook's original call. The aura *application* hooks
|
||||
// fire off a separate SMSG_UPDATE_OBJECT, so RememberPlayerCast's handoff to
|
||||
// them is unaffected by the move.
|
||||
void HandleSpellGo(uint64_t caster, uint32_t spellId, const uint64_t *targets,
|
||||
int numTargets) {
|
||||
if (caster == 0 || spellId == 0)
|
||||
return;
|
||||
|
||||
// Feed the totem tracker BEFORE the aura gate below — a totem summon
|
||||
// applies no aura, so it would otherwise be dropped. Player casts only.
|
||||
if (caster == Unit::Identity::PlayerGuid())
|
||||
// Feed the totem tracker + fire UNIT_SPELLCAST_SUCCEEDED BEFORE the aura
|
||||
// gate below — a totem summon (and any non-aura spell) applies no aura,
|
||||
// so it would otherwise be dropped. SPELL_GO is "the spell went off", so
|
||||
// this is the succeeded signal for instants too. Player casts only.
|
||||
if (caster == Unit::Identity::PlayerGuid()) {
|
||||
Totem::Tracker::OnPlayerSpellGo(spellId);
|
||||
Spell::CastEvents::OnPlayerSucceeded(static_cast<int>(spellId));
|
||||
} else {
|
||||
Spell::CastEvents::OnRemoteSucceeded(caster, static_cast<int>(spellId));
|
||||
}
|
||||
|
||||
// Mirror server-side duration edits the client is never told about
|
||||
// (Conflagrate -3s Immolate, Molten Blast refresh Flame Shock, …). Runs
|
||||
@@ -624,9 +617,33 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
|
||||
KIND_UNKNOWN);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hook{Offsets::FUN_SPELL_GO,
|
||||
reinterpret_cast<void *>(&SpellGo_h),
|
||||
reinterpret_cast<void **>(&g_origSpellGo)};
|
||||
// SMSG_SPELL_GO parse (funnel subscriber). At the leaf handler the engine has
|
||||
// pre-decoded itemGuid/casterGuid/spellId; here we're at the raw body, so we
|
||||
// decode them ourselves before the hit list:
|
||||
// itemGuid(packed), casterGuid(packed), spellId(u32), castFlags(i16),
|
||||
// numHit(u8), hitGuids(u64 × numHit).
|
||||
void ParseSpellGo(CDataStore *packet) {
|
||||
Net::ReadPackedGuid(packet); // itemGuid (unused)
|
||||
const uint64_t caster = Net::ReadPackedGuid(packet);
|
||||
const uint32_t spellId = Net::Read<uint32_t>(packet);
|
||||
Net::Read<int16_t>(packet); // castFlags (unused)
|
||||
const uint8_t numHit = Net::Read<uint8_t>(packet);
|
||||
uint64_t targets[kMaxTargets];
|
||||
int numTargets = 0;
|
||||
for (int i = 0; i < numHit; ++i) {
|
||||
const uint64_t guid = Net::Read<uint64_t>(packet);
|
||||
if (numTargets < kMaxTargets)
|
||||
targets[numTargets++] = guid;
|
||||
}
|
||||
HandleSpellGo(caster, spellId, targets, numTargets);
|
||||
}
|
||||
|
||||
void SpellGoSub(uint32_t opcode, CDataStore *packet) {
|
||||
if (opcode == Offsets::SMSG_SPELL_GO && packet != nullptr)
|
||||
ParseSpellGo(packet);
|
||||
}
|
||||
|
||||
const Net::PacketDispatch::AutoSubscribe _spellGoSub{&SpellGoSub};
|
||||
|
||||
// ---- Aura-application co-hooks (timing for proc / triggered auras) -------
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
#include "event/Custom.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Bag::UpdateDelayed {
|
||||
|
||||
namespace {
|
||||
@@ -127,6 +129,19 @@ unsigned __fastcall KeyringDescChange_h(void *bagDesc, void *edx,
|
||||
void OnWorldTick() {
|
||||
if (!g_pending)
|
||||
return;
|
||||
// Bag changes aren't meaningful until the player is actually in the world.
|
||||
// During any loading screen (initial login, zone/instance transition) the
|
||||
// engine re-populates bag slots as objects stream in (setting g_pending)
|
||||
// while still behind the screen; the per-frame world tick runs there too,
|
||||
// so firing BAG_UPDATE_DELAYED would make addons rescan bags repeatedly
|
||||
// mid-load for no real change. Hold the pending flag — do NOT clear it —
|
||||
// until the engine's in-world flag is set; the first in-world frame then
|
||||
// emits a single DELAYED covering the settled inventory (matching retail's
|
||||
// one BAG_UPDATE_DELAYED per load). VAR_IN_WORLD clears on every transition,
|
||||
// so this re-suppresses on later zone changes too, not just initial login.
|
||||
if (*reinterpret_cast<const volatile uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
|
||||
return;
|
||||
g_pending = false;
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
if (slot >= 0)
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
TargetUnit("focus");
|
||||
</Binding>
|
||||
<Binding name="AOELOOT">
|
||||
if not C_Loot.IsScanInProgress() then
|
||||
C_Loot.ScanNearbyLoot()
|
||||
end
|
||||
C_Loot.LootAllCorpses()
|
||||
</Binding>
|
||||
</Bindings>
|
||||
|
||||
@@ -282,6 +282,22 @@ int LookupByName(const char *name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool HasListeners(int slot) {
|
||||
if (slot < 0)
|
||||
return false;
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
if (base == nullptr || slot >= count)
|
||||
return false;
|
||||
// The entry's chain head (`+0x0C`): a populated subscriber chain is a
|
||||
// non-zero pointer with the low bit clear; `0` or an odd (tagged)
|
||||
// value is the empty self-sentinel — same test the grow-safety scan
|
||||
// uses. So this is true iff at least one frame registered for the event.
|
||||
const uint32_t head = *reinterpret_cast<const uint32_t *>(
|
||||
base + slot * Offsets::EVENT_ENTRY_STRIDE + Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
return head != 0 && (head & 1) == 0;
|
||||
}
|
||||
|
||||
void RetryClaims() {
|
||||
// Grow the table first if the low gap pool is short (dormant valve).
|
||||
// Runs at most once per table build; on the first call every chain is
|
||||
|
||||
@@ -64,6 +64,15 @@ int Lookup(const char *name);
|
||||
// fire ourselves (e.g., polyfilling missing event dispatches).
|
||||
int LookupByName(const char *name);
|
||||
|
||||
// True iff at least one frame is currently registered for the event in
|
||||
// `slot` (its subscriber chain is non-empty). Reads the entry's chain
|
||||
// head at `+0x0C` — a couple of pointer derefs, no allocation. Use this
|
||||
// to gate expensive per-fire work (arg synthesis, DBC lookups) so an
|
||||
// event nobody listens to costs almost nothing: `if (HasListeners(slot))
|
||||
// { …build args…; Fire(slot, …); }`. `false` for `slot < 0` or a slot
|
||||
// past the live table.
|
||||
bool HasListeners(int slot);
|
||||
|
||||
// Dispatches a custom event via the engine's printf-style event
|
||||
// dispatcher at `FUN_FIRE_EVENT` (`0x00703F50`). `format` is a
|
||||
// concatenation of `%d` (int), `%u` (uint), `%f` (double), `%s`
|
||||
|
||||
@@ -0,0 +1,953 @@
|
||||
// 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/>.
|
||||
|
||||
// Frame attributes + unit-frame mouseover/click backport.
|
||||
//
|
||||
// Backports `frame:SetAttribute` / `frame:SetAttributeNoHandler` /
|
||||
// `frame:ClearAttribute` / `frame:GetAttribute` to 1.12 as native methods on the base Frame registry
|
||||
// (every frame gets them, like the modern clients). Vanilla has no attribute
|
||||
// system at all — it arrived in 2.0 with secure frames. Attributes are just a
|
||||
// per-frame, case-insensitive key→value store (verified from the 3.3.5
|
||||
// implementation: a name→Lua-value map plus a getter with prefix/name/suffix
|
||||
// wildcard precedence). We store the map in the Lua registry keyed by the frame
|
||||
// (registry[frame]) — invisible to Lua script and tamper-proof, like retail's
|
||||
// C-side attribute store (see PushSub).
|
||||
//
|
||||
// The headline use — SecureUnitButton behavior, minus the secure wrapper (1.12
|
||||
// has no combat lockdown or taint, so protected actions just work):
|
||||
//
|
||||
// unitFrame:SetAttribute("unit", "party1") -- hover → mouseover = party1
|
||||
// unitFrame:SetAttribute("type1", "target") -- left-click → target party1
|
||||
//
|
||||
// ── Mouseover (needs C — the engine's mouseover is a GUID slot) ──
|
||||
//
|
||||
// Installing OnEnter/OnLeave would be clobbered by an addon's own
|
||||
// `SetScript("OnEnter", …)` (pfUI sets `unit` before its scripts), and there's
|
||||
// nothing to read from Lua anyway — the mouseover unit is an engine global. So
|
||||
// we mirror retail / SuperWoW's `SetMouseoverUnit`: watch the engine's
|
||||
// mouse-focus frame (`*(*VAR_UI_CONTEXT_PTR + OFF_UI_CONTEXT_MOUSE_FOCUS)`, the
|
||||
// CFrameScriptObject* `GetMouseFocus` returns) once per frame on the shared
|
||||
// WorldTick, and when it's a `unit`-attributed frame call the engine's FULL
|
||||
// mouseover setter (FUN_00492890) — 1:1 with hovering the unit's 3D model:
|
||||
// model highlight, mouseover tooltip, UPDATE_MOUSEOVER_UNIT, and the GUID slot.
|
||||
// Stomp-proof: the engine's own 3D-hover setter is event-driven, so nothing
|
||||
// overwrites the slot while the cursor is over UI.
|
||||
//
|
||||
// ── Clicks (the frame's own OnClick — scoped, native) ──
|
||||
//
|
||||
// Clicks are events, not state, so they belong on the frame's OnClick — but
|
||||
// ONLY on frames that opt in with a `type` attribute. When a `type*` attribute
|
||||
// is set we install a chained OnClick on that frame (protected, because OnClick
|
||||
// is Button-only). It mirrors retail's SecureActionButton_OnClick: resolve ONE
|
||||
// verb per click from the modifier/button-qualified `type` attribute, perform
|
||||
// it, done. No global hook, no handler on frames that never asked for one.
|
||||
//
|
||||
// Resolution (retail-style): the attribute is `[prefix]type[suffix]`, where the
|
||||
// prefix is the held modifiers ("alt-"/"ctrl-"/"shift-") and the suffix is the
|
||||
// button number (1=Left, 2=Right, …). So `type1`, `shift-type1`, `type` all
|
||||
// resolve via ReadModAttr's precedence. Verbs: `target` (with the engine's
|
||||
// default-interaction precedence — pending spell / cursor item cast/drop on the
|
||||
// unit instead of switching target), `assist`, `focus`, `spell` (reads the
|
||||
// `spell` attribute and casts it on the unit via our native C_Spell.CastAtUnit —
|
||||
// the unit's GUID goes straight to the cast dispatcher, no target juggling, and
|
||||
// ground-target spells land at the unit's feet), `item` (uses the `item`
|
||||
// attribute — a name/itemID/link via C_Item.UseItemByName with the unit as the
|
||||
// use target, or a "bag slot" string like "0 1" via UseContainerItem; the
|
||||
// deprecated `bag`/`slot` attributes also work), `macro` (from the
|
||||
// `macrotext`/`macro` attribute — prefers an addon RunMacro, else runs natively
|
||||
// via the stock ChatEdit_ParseText), `stopcasting`, `menu`/`togglemenu` (pops
|
||||
// the standard unit dropdown at the cursor via the addon's
|
||||
// ClassicAPI_ToggleUnitMenu). Actions call ordinary Lua globals where the entry
|
||||
// is the engine's (TargetUnit, IsAltKeyDown, …); where we own a C++ module the
|
||||
// dispatch goes straight to it, no Lua round-trip (Spell::AtUnit for `spell`,
|
||||
// Unit::Focus for `focus`). All run under the engine's protected OnClick.
|
||||
//
|
||||
// One verb per click is the point: because we own the click when a verb
|
||||
// resolves (and only then chain the previous handler), a configured `type1` no
|
||||
// longer runs *alongside* the addon's own conditional OnClick — that double
|
||||
// dispatch (our fixed "target" + pfUI's ClickAction) was the conflict. An addon
|
||||
// expresses all its click behavior as attributes; unconfigured clicks fall
|
||||
// through to its OnClick (e.g. a right-click menu).
|
||||
//
|
||||
// Ordering / clobbering: it chains the handler present when `type*` is set, so
|
||||
// set `type*` AFTER the frame's own `OnClick`. Addons that re-`SetScript`
|
||||
// `OnClick` later (pfUI does on every raid relayout) replace our closure — so
|
||||
// re-set `type*` after re-SetScript to recover. Re-wiring is safe: WireOnClick
|
||||
// re-wraps a clobbered (Lua) handler but skips its own C closure, so it never
|
||||
// double-chains. The frame must be a Button registered for the relevant clicks
|
||||
// (`RegisterForClicks`) — left is the Button default; right needs
|
||||
// `RegisterForClicks("RightButtonUp")`, which real unit frames do.
|
||||
//
|
||||
// `OnAttributeChanged` is a real SetScript-able handler: SetAttribute (not
|
||||
// SetAttributeNoHandler) fires it with `this` = frame, `arg1` = name, `arg2` =
|
||||
// value. Implemented by co-hooking the base-frame script-name resolver
|
||||
// (`FUN_FRAME_SCRIPT_RESOLVER`) and handing out an external per-frame cell for
|
||||
// that name — the `Tooltip::SetEvents` analog, applied to every frame.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "cursor/Info.h"
|
||||
#include "spell/AtCursor.h"
|
||||
#include "spell/AtUnit.h"
|
||||
#include "tick/WorldTick.h"
|
||||
#include "unit/Focus.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace Frame::Attributes {
|
||||
|
||||
namespace {
|
||||
|
||||
using ScriptFn_t = int(__fastcall *)(void *L);
|
||||
|
||||
int CallScript(uintptr_t fn, void *L) {
|
||||
return reinterpret_cast<ScriptFn_t>(fn)(L);
|
||||
}
|
||||
|
||||
// ---- small string helpers --------------------------------------------------
|
||||
|
||||
void LowerCopy(char *dst, const char *src, size_t n) {
|
||||
size_t i = 0;
|
||||
for (; src && src[i] && i + 1 < n; ++i)
|
||||
dst[i] = static_cast<char>(std::tolower(static_cast<unsigned char>(src[i])));
|
||||
dst[i] = '\0';
|
||||
}
|
||||
|
||||
// Bounded, always-terminated copy (avoids the C4996 strncpy/strcpy warnings).
|
||||
void BoundedCopy(char *dst, const char *src, size_t n) {
|
||||
size_t i = 0;
|
||||
for (; src && src[i] && i + 1 < n; ++i)
|
||||
dst[i] = src[i];
|
||||
dst[i] = '\0';
|
||||
}
|
||||
|
||||
// dst = tolower(a .. b .. c). Truncates at n-1.
|
||||
void Compose3Lower(char *dst, size_t n, const char *a, const char *b, const char *c) {
|
||||
size_t i = 0;
|
||||
const char *parts[3] = {a, b, c};
|
||||
for (const char *s : parts)
|
||||
for (; s && *s && i + 1 < n; ++s)
|
||||
dst[i++] = static_cast<char>(std::tolower(static_cast<unsigned char>(*s)));
|
||||
dst[i] = '\0';
|
||||
}
|
||||
|
||||
// ASCII case-insensitive full-string equality.
|
||||
bool EqI(const char *a, const char *b) {
|
||||
for (; *a && *b; ++a, ++b)
|
||||
if (std::tolower(static_cast<unsigned char>(*a)) !=
|
||||
std::tolower(static_cast<unsigned char>(*b)))
|
||||
return false;
|
||||
return *a == *b;
|
||||
}
|
||||
|
||||
// True if `lname` (already lowercase) is a click "type" action attribute: an
|
||||
// optional modifier prefix (alt-/ctrl-/shift-, any order/combo) followed by
|
||||
// "type" and an optional button digit — i.e. anything the click resolver
|
||||
// (ButtonSuffix + ReadModAttr) reads as a verb selector: type, type1..type5,
|
||||
// shift-type1, alt-ctrl-type2, … . This is the exact set WireOnClick must
|
||||
// (re)install for; keying the wiring off it (rather than a fixed
|
||||
// type/type1/type2 list) means a middle-click (type3) or modifier-only config
|
||||
// still installs the chained OnClick.
|
||||
bool IsTypeAttr(const char *lname) {
|
||||
const char *p = lname;
|
||||
for (bool advanced = true; advanced;) {
|
||||
if (std::strncmp(p, "alt-", 4) == 0) p += 4;
|
||||
else if (std::strncmp(p, "ctrl-", 5) == 0) p += 5;
|
||||
else if (std::strncmp(p, "shift-", 6) == 0) p += 6;
|
||||
else advanced = false;
|
||||
}
|
||||
if (std::strncmp(p, "type", 4) != 0)
|
||||
return false;
|
||||
p += 4;
|
||||
if (p[0] == '\0')
|
||||
return true; // "type"
|
||||
return p[0] >= '1' && p[0] <= '5' && p[1] == '\0'; // "typeN"
|
||||
}
|
||||
|
||||
// ---- attribute storage (in the Lua registry, keyed by the frame) -----------
|
||||
|
||||
// Pushes the frame's attribute subtable onto the stack. Stored as
|
||||
// registry[frame] in the Lua registry (LUA_REGISTRYINDEX) rather than as a
|
||||
// field on the frame's own table — the registry is C-side and has no _G path,
|
||||
// so the store is invisible to `pairs(frame)` and can't be read or tampered
|
||||
// with from Lua, matching how retail keeps attributes C-side. With create=true,
|
||||
// lazily creates and stores it. Returns true iff a subtable is left on top;
|
||||
// false (nothing left on the stack) when absent and !create. 1.12 frames are
|
||||
// immortal, so the strong registry key never leaks.
|
||||
bool PushSub(void *L, int frameIdx, bool create) {
|
||||
Game::Lua::PushValue(L, frameIdx); // [.., frame] (key)
|
||||
Game::Lua::RawGet(L, Game::Lua::REGISTRY_INDEX); // [.., sub|nil]
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
|
||||
return true; // [.., sub]
|
||||
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1); // drop the nil -> [..]
|
||||
if (!create)
|
||||
return false;
|
||||
Game::Lua::NewTable(L); // [.., sub]
|
||||
Game::Lua::PushValue(L, frameIdx); // [.., sub, frame] (key)
|
||||
Game::Lua::PushValue(L, -2); // [.., sub, frame, sub] (value)
|
||||
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX); // registry[frame] = sub -> [.., sub]
|
||||
return true;
|
||||
}
|
||||
|
||||
// Copies string attribute `lname` (already lowercase) of the frame at
|
||||
// frameIdx into buf. Returns true iff a string value was found. Restores the
|
||||
// stack.
|
||||
bool CopyAttr(void *L, int frameIdx, const char *lname, char *buf, size_t n) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
bool ok = false;
|
||||
if (PushSub(L, frameIdx, false)) { // [.., sub]
|
||||
const int si = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, lname);
|
||||
Game::Lua::RawGet(L, si); // [.., sub, val]
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING) {
|
||||
const char *s = Game::Lua::ToString(L, -1);
|
||||
if (s) {
|
||||
BoundedCopy(buf, s, n);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// If the frame's attribute subtable has a non-nil value for keyLower, leave that
|
||||
// value on the stack top and return true; otherwise restore the stack and
|
||||
// return false.
|
||||
bool TryPushValue(void *L, int frameIdx, const char *keyLower) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
if (PushSub(L, frameIdx, false)) { // [.., sub]
|
||||
const int si = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, keyLower);
|
||||
Game::Lua::RawGet(L, si); // [.., sub, val]
|
||||
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_NIL) {
|
||||
Game::Lua::Remove(L, si); // [.., val]
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unit token → GUID goes through `Unit::Identity::GuidForToken` (engine
|
||||
// resolver + `"player"` fast-path, handling our focus / nameplateN / raw-GUID
|
||||
// extensions). For recognized tokens it never raises; unit attributes are
|
||||
// tokens by contract.
|
||||
|
||||
// ---- Lua-global call helpers (used by the click dispatcher) -----------------
|
||||
//
|
||||
// The click dispatch mirrors retail's SecureActionButton_OnClick: it's C. It
|
||||
// performs engine actions by calling ordinary Lua globals (TargetUnit,
|
||||
// IsAltKeyDown, …) and our own actions by calling the C++ module directly
|
||||
// (Spell::AtUnit, Unit::Focus). Both run under the engine's protected OnClick
|
||||
// invocation, so a Lua error inside one is caught there (no crash).
|
||||
|
||||
// The plain global-call primitives (push-if-function, call with no/one arg)
|
||||
// live in Game::Lua now (PushGlobalFunction / CallGlobal / CallGlobalString) —
|
||||
// shared with the other modules that dispatch to FrameXML globals by name. The
|
||||
// two shapes below are specific enough to stay here.
|
||||
|
||||
// _G[name]() -> boolean (false if the global isn't a function). Distinct from
|
||||
// Game::Lua::CallGlobal in that it returns the call's boolean RESULT (used for
|
||||
// the IsAltKeyDown/IsShiftKeyDown modifier probes).
|
||||
bool CallBoolGlobal(void *L, const char *name) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
bool r = false;
|
||||
if (Game::Lua::PushGlobalFunction(L, name)) {
|
||||
Game::Lua::Call(L, 0, 1);
|
||||
r = Game::Lua::ToBoolean(L, -1) != 0;
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return r;
|
||||
}
|
||||
|
||||
// _G[table][method](a, b) — two string args (null → nil, since PushString maps
|
||||
// NULL to nil), no results. Returns true iff the method resolved to a function
|
||||
// and got called. Used when the target is our own C_* function but the call must
|
||||
// cross the Lua boundary — e.g. C_Item.UseItemByName, whose bag walk does
|
||||
// SetTop(L, 0); going through lua_call gives it its own stack frame so that reset
|
||||
// clears only its frame, not the dispatcher's (a direct C++ call would wipe our
|
||||
// stack — the reason `spell`, which is stack-free, calls Spell::AtUnit directly
|
||||
// while `item` routes through here).
|
||||
bool CallTableFunc2Str(void *L, const char *table, const char *method,
|
||||
const char *a, const char *b) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
bool called = false;
|
||||
Game::Lua::PushString(L, table);
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // _G[table]
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE) {
|
||||
const int t = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, method);
|
||||
Game::Lua::GetTable(L, t); // _G[table][method]
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::PushString(L, a);
|
||||
Game::Lua::PushString(L, b);
|
||||
Game::Lua::Call(L, 2, 0);
|
||||
called = true;
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return called;
|
||||
}
|
||||
|
||||
// ---- macrotext execution (stock ChatEdit_ParseText, no addon dependency) ----
|
||||
//
|
||||
// 1.12 has no `RunMacroText`/`RunMacro` global — those are addon shims (pfUI
|
||||
// wraps `ChatEdit_ParseText`; SuperCleveRoidMacros ships its own `RunMacro`).
|
||||
// To run macro text without depending on an addon, we replicate pfUI's shim in
|
||||
// C: build a throwaway "edit box" whose `GetText` returns the line and whose
|
||||
// every other method is a harmless no-op (via an `__index` metamethod), then
|
||||
// hand it to the stock FrameXML `ChatEdit_ParseText(editBox, 1)` — the same
|
||||
// path the real chat box uses to dispatch a slash command / send a line.
|
||||
|
||||
// GetText: returns upvalue(1), the captured line.
|
||||
int __fastcall MacroGetText_c(void *L) {
|
||||
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// A no-op standing in for any edit-box method the parser happens to call.
|
||||
int __fastcall MacroNoop_c(void *) { return 0; }
|
||||
|
||||
// __index(tab, key): hand back the no-op so `editBox:AnyMethod()` is safe.
|
||||
int __fastcall MacroIndex_c(void *L) {
|
||||
Game::Lua::PushCClosure(L, &MacroNoop_c, 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Runs a single macro line through the stock chat parser.
|
||||
void RunMacroLineC(void *L, const char *line, size_t len) {
|
||||
if (len == 0) return;
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
|
||||
Game::Lua::NewTable(L); // fake editBox
|
||||
const int obj = Game::Lua::GetTop(L);
|
||||
|
||||
Game::Lua::PushString(L, "GetText"); // obj.GetText = closure over line
|
||||
Game::Lua::PushLString(L, line, static_cast<unsigned int>(len));
|
||||
Game::Lua::PushCClosure(L, &MacroGetText_c, 1);
|
||||
Game::Lua::SetTable(L, obj);
|
||||
|
||||
Game::Lua::NewTable(L); // metatable { __index = noop }
|
||||
const int mt = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, "__index");
|
||||
Game::Lua::PushCClosure(L, &MacroIndex_c, 0);
|
||||
Game::Lua::SetTable(L, mt);
|
||||
|
||||
// No lua_setmetatable binding — use the Lua global.
|
||||
if (Game::Lua::PushGlobalFunction(L, "setmetatable")) {
|
||||
Game::Lua::PushValue(L, obj);
|
||||
Game::Lua::PushValue(L, mt);
|
||||
Game::Lua::Call(L, 2, 0);
|
||||
}
|
||||
|
||||
if (Game::Lua::PushGlobalFunction(L, "ChatEdit_ParseText")) {
|
||||
Game::Lua::PushValue(L, obj);
|
||||
Game::Lua::PushNumber(L, 1); // send = 1
|
||||
Game::Lua::Call(L, 2, 0);
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, top);
|
||||
}
|
||||
|
||||
// Runs macro text one line at a time — a real macro is line-delimited, and a
|
||||
// single ChatEdit_ParseText call only dispatches one command.
|
||||
void RunMacroTextC(void *L, const char *text) {
|
||||
if (!text) return;
|
||||
for (const char *p = text; *p;) {
|
||||
const char *nl = p;
|
||||
while (*nl && *nl != '\n') ++nl;
|
||||
RunMacroLineC(L, p, static_cast<size_t>(nl - p));
|
||||
p = (*nl == '\n') ? nl + 1 : nl;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mouse-focus poll (drives the native mouseover slot) -------------------
|
||||
|
||||
// frame CFrameScriptObject* → current `unit` token, for the mouse-focus poll.
|
||||
// Keyed by the same pointer the engine stores at the mouse-focus slot. Single-
|
||||
// threaded (Lua + WorldTick on the main thread), so no synchronization needed.
|
||||
std::unordered_map<const void *, std::string> g_unitByFrame;
|
||||
|
||||
// The last GUID *we* wrote into the mouseover slot. We only touch the slot on a
|
||||
// change of our own value, so when the cursor isn't over a unit frame (guid 0)
|
||||
// we never clobber the engine's own 3D mouseover.
|
||||
uint64_t g_lastSet = 0;
|
||||
|
||||
const void *CurrentMouseFocus() {
|
||||
const void *ctx = *reinterpret_cast<const void *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
|
||||
if (ctx == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const void *const *>(
|
||||
reinterpret_cast<const uint8_t *>(ctx) + Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
|
||||
}
|
||||
|
||||
// The engine's real mouseover setter — `__stdcall(guidLo, guidHi, prevLo,
|
||||
// prevHi)`. Highlights the unit's model, builds the mouseover tooltip, fires
|
||||
// UPDATE_MOUSEOVER_UNIT, and writes the GUID slot; pass 0,0 for the optional
|
||||
// previous, and (0,0,0,0) to clear. Same address `Unit::Mouseover` co-hooks, so
|
||||
// calling it here goes through that hook (loss events keep firing correctly).
|
||||
void CallEngineMouseover(uint64_t guid) {
|
||||
reinterpret_cast<void(__stdcall *)(uint32_t, uint32_t, uint32_t, uint32_t)>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SET_MOUSEOVER_UNIT))(
|
||||
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32), 0, 0);
|
||||
}
|
||||
|
||||
void MouseoverTick() {
|
||||
const void *focus = CurrentMouseFocus();
|
||||
|
||||
uint64_t guid = 0;
|
||||
if (focus != nullptr) {
|
||||
auto it = g_unitByFrame.find(focus);
|
||||
if (it != g_unitByFrame.end())
|
||||
guid = Unit::Identity::GuidForToken(
|
||||
it->second.c_str()); // live — follows target changes
|
||||
}
|
||||
|
||||
if (guid != g_lastSet) {
|
||||
CallEngineMouseover(guid); // sets slot + highlight + tooltip + event
|
||||
g_lastSet = guid;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- click dispatch (the frame's own chained OnClick) ----------------------
|
||||
|
||||
// Runs the chained-onto previous handler (upvalue 1), vanilla-style with no
|
||||
// args (it reads the `this`/`arg1` globals the engine set for this invocation).
|
||||
void ChainOld(void *L) {
|
||||
if (Game::Lua::Type(L, Game::Lua::UpvalueIndex(1)) == Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
|
||||
Game::Lua::Call(L, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the modifier prefix ("alt-"/"ctrl-"/"shift-", in that order — retail's
|
||||
// order) from the live key state, e.g. "alt-shift-". Empty when none held.
|
||||
void BuildModifierPrefix(void *L, char *buf, size_t n) {
|
||||
buf[0] = '\0';
|
||||
size_t i = 0;
|
||||
auto add = [&](const char *s) {
|
||||
for (const char *p = s; *p && i + 1 < n; ++p)
|
||||
buf[i++] = *p;
|
||||
buf[i] = '\0';
|
||||
};
|
||||
if (CallBoolGlobal(L, "IsAltKeyDown")) add("alt-");
|
||||
if (CallBoolGlobal(L, "IsControlKeyDown")) add("ctrl-");
|
||||
if (CallBoolGlobal(L, "IsShiftKeyDown")) add("shift-");
|
||||
}
|
||||
|
||||
// Button name -> attribute suffix (retail's convention: the button number).
|
||||
const char *ButtonSuffix(const char *btn) {
|
||||
if (EqI(btn, "RightButton")) return "2";
|
||||
if (EqI(btn, "MiddleButton")) return "3";
|
||||
if (EqI(btn, "Button4")) return "4";
|
||||
if (EqI(btn, "Button5")) return "5";
|
||||
return "1"; // LeftButton / unknown
|
||||
}
|
||||
|
||||
// Resolves a modified attribute into `buf`. Precedence (practical subset of the
|
||||
// GetAttribute wildcard rules): prefix..name..suffix (e.g. "shift-type1"),
|
||||
// then name..suffix ("type1"), then name ("type"). So a plain `type1` still
|
||||
// applies under any modifier unless a modifier-specific attribute overrides it.
|
||||
bool ReadModAttr(void *L, int fi, const char *prefix, const char *name,
|
||||
const char *suffix, char *buf, size_t n) {
|
||||
char key[128];
|
||||
Compose3Lower(key, sizeof key, prefix, name, suffix);
|
||||
if (CopyAttr(L, fi, key, buf, n)) return true;
|
||||
Compose3Lower(key, sizeof key, "", name, suffix);
|
||||
if (CopyAttr(L, fi, key, buf, n)) return true;
|
||||
Compose3Lower(key, sizeof key, "", name, "");
|
||||
return CopyAttr(L, fi, key, buf, n);
|
||||
}
|
||||
|
||||
// Reads attribute `keyLower` as an int (stored as a number, or a numeric
|
||||
// string) into *out. Leaves the stack as it found it. Returns true on success.
|
||||
bool ReadAttrInt(void *L, int fi, const char *keyLower, int *out) {
|
||||
bool ok = false;
|
||||
if (TryPushValue(L, fi, keyLower)) { // value left on top
|
||||
const int t = Game::Lua::Type(L, -1);
|
||||
if (t == Game::Lua::TYPE_NUMBER) {
|
||||
*out = static_cast<int>(Game::Lua::ToNumber(L, -1));
|
||||
ok = true;
|
||||
} else if (t == Game::Lua::TYPE_STRING) {
|
||||
const char *s = Game::Lua::ToString(L, -1);
|
||||
char *end = nullptr;
|
||||
const long v = s ? std::strtol(s, &end, 10) : 0;
|
||||
if (s && end != s) {
|
||||
*out = static_cast<int>(v);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1); // pop the peeked value
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Numeric analog of ReadModAttr (prefix..name..suffix precedence).
|
||||
bool ReadModAttrInt(void *L, int fi, const char *prefix, const char *name,
|
||||
const char *suffix, int *out) {
|
||||
char key[128];
|
||||
Compose3Lower(key, sizeof key, prefix, name, suffix);
|
||||
if (ReadAttrInt(L, fi, key, out)) return true;
|
||||
Compose3Lower(key, sizeof key, "", name, suffix);
|
||||
if (ReadAttrInt(L, fi, key, out)) return true;
|
||||
Compose3Lower(key, sizeof key, "", name, "");
|
||||
return ReadAttrInt(L, fi, key, out);
|
||||
}
|
||||
|
||||
// Parses a "bag slot" string (two integers, e.g. "0 1") into bag/slot. Returns
|
||||
// false unless the whole string is exactly two integers, so a normal item name
|
||||
// or a bare itemID ("12345") falls through to the name path.
|
||||
bool ParseBagSlot(const char *s, int *bag, int *slot) {
|
||||
char *end = nullptr;
|
||||
const long b = std::strtol(s, &end, 10);
|
||||
if (end == s) return false; // not a leading number
|
||||
const char *p = end;
|
||||
const long sl = std::strtol(p, &end, 10);
|
||||
if (end == p) return false; // only one number
|
||||
while (*end == ' ' || *end == '\t') ++end;
|
||||
if (*end != '\0') return false; // trailing junk
|
||||
*bag = static_cast<int>(b);
|
||||
*slot = static_cast<int>(sl);
|
||||
return true;
|
||||
}
|
||||
|
||||
// _G[name](a, b) — two number args, no results. Returns true iff called.
|
||||
bool CallGlobalNum2(void *L, const char *name, double a, double b) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
const bool called = Game::Lua::PushGlobalFunction(L, name);
|
||||
if (called) {
|
||||
Game::Lua::PushNumber(L, a);
|
||||
Game::Lua::PushNumber(L, b);
|
||||
Game::Lua::Call(L, 2, 0);
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return called;
|
||||
}
|
||||
|
||||
// Performs the resolved `verb` on `unit` (a token attribute value, may be null).
|
||||
// Returns true if it owned the click (so the chained handler is skipped).
|
||||
bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
|
||||
const char *verb, const char *unit) {
|
||||
if (EqI(verb, "target")) {
|
||||
if (!unit) return false;
|
||||
// `unit="none"` clears the target (retail's SecureActionButton behavior).
|
||||
if (EqI(unit, "none")) {
|
||||
Game::Lua::CallGlobal(L, "ClearTarget");
|
||||
return true;
|
||||
}
|
||||
// Cursor / pending-spell take precedence, matching the engine's default
|
||||
// unit interaction — cast the pending spell / drop the item on the unit
|
||||
// instead of switching target. The two predicates are ours (direct C++);
|
||||
// the actions are engine-only, so they go through the Lua globals (which
|
||||
// are the engine's own entries, with token→GUID resolution).
|
||||
if (Spell::AtCursor::IsPlacementActive())
|
||||
Game::Lua::CallGlobalString(L, "SpellTargetUnit", unit);
|
||||
else if (Cursor::Info::HasItem())
|
||||
Game::Lua::CallGlobalString(L, "DropItemOnUnit", unit);
|
||||
else
|
||||
Game::Lua::CallGlobalString(L, "TargetUnit", unit);
|
||||
return true;
|
||||
}
|
||||
if (EqI(verb, "assist")) {
|
||||
if (!unit) return false;
|
||||
Game::Lua::CallGlobalString(L, "AssistUnit", unit);
|
||||
return true;
|
||||
}
|
||||
if (EqI(verb, "focus")) {
|
||||
if (!unit) return false;
|
||||
Unit::Focus::Set(Unit::Identity::GuidForToken(unit));
|
||||
return true;
|
||||
}
|
||||
if (EqI(verb, "spell")) {
|
||||
if (!unit) return false;
|
||||
char spell[128];
|
||||
if (!ReadModAttr(L, fi, prefix, "spell", suffix, spell, sizeof spell))
|
||||
return false;
|
||||
Spell::AtUnit::CastByName(spell, unit);
|
||||
return true;
|
||||
}
|
||||
if (EqI(verb, "item")) {
|
||||
char item[128];
|
||||
int bag, slot;
|
||||
if (ReadModAttr(L, fi, prefix, "item", suffix, item, sizeof item)) {
|
||||
if (ParseBagSlot(item, &bag, &slot))
|
||||
CallGlobalNum2(L, "UseContainerItem", bag, slot);
|
||||
else
|
||||
CallTableFunc2Str(L, "C_Item", "UseItemByName", item, unit);
|
||||
return true;
|
||||
}
|
||||
// Deprecated form: no `item`, take the `bag` + `slot` attributes.
|
||||
if (ReadModAttrInt(L, fi, prefix, "bag", suffix, &bag) &&
|
||||
ReadModAttrInt(L, fi, prefix, "slot", suffix, &slot)) {
|
||||
CallGlobalNum2(L, "UseContainerItem", bag, slot);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (EqI(verb, "macro")) {
|
||||
char macro[512];
|
||||
if (!ReadModAttr(L, fi, prefix, "macrotext", suffix, macro, sizeof macro) &&
|
||||
!ReadModAttr(L, fi, prefix, "macro", suffix, macro, sizeof macro))
|
||||
return false;
|
||||
// Prefer an addon-provided RunMacro (SuperCleveRoidMacros, pfUI, …) — it
|
||||
// handles named macros and extended macro text; fall back to the stock
|
||||
// ChatEdit_ParseText path when no RunMacro global is present.
|
||||
if (!Game::Lua::CallGlobalString(L, "RunMacro", macro))
|
||||
RunMacroTextC(L, macro);
|
||||
return true;
|
||||
}
|
||||
if (EqI(verb, "stop") || EqI(verb, "stopcasting")) {
|
||||
Game::Lua::CallGlobal(L, "SpellStopCasting");
|
||||
return true;
|
||||
}
|
||||
if (EqI(verb, "menu") || EqI(verb, "togglemenu")) {
|
||||
if (!unit) return false;
|
||||
// The unit dropdown is pure FrameXML work (UnitPopup + ToggleDropDown),
|
||||
// so it lives in the !!!ClassicAPI addon; we just pop it at the cursor.
|
||||
Game::Lua::CallGlobalString(L, "ClassicAPI_ToggleUnitMenu", unit);
|
||||
return true;
|
||||
}
|
||||
// Unknown verb → not handled here; the chained handler runs.
|
||||
return false;
|
||||
}
|
||||
|
||||
// The chained OnClick handler. Upvalues: 1 = previous handler (or nil), 2 = the
|
||||
// frame (captured at install time, so we don't depend on the `this` global).
|
||||
// Resolves one verb per click from the (modifier/button) `type` attribute and
|
||||
// performs it — retail's one-action-per-click model. Only chains the previous
|
||||
// handler when we DIDN'T own the click, so a configured `type1` no longer runs
|
||||
// alongside the addon's own OnClick (that double-dispatch was the conflict).
|
||||
int __fastcall OnClick_c(void *L) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(2)); // frame
|
||||
const int fi = Game::Lua::GetTop(L);
|
||||
bool handled = false;
|
||||
if (Game::Lua::Type(L, fi) == Game::Lua::TYPE_TABLE) {
|
||||
char btn[32] = {0};
|
||||
Game::Lua::PushString(L, "arg1");
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING) {
|
||||
const char *b = Game::Lua::ToString(L, -1);
|
||||
if (b)
|
||||
BoundedCopy(btn, b, sizeof btn);
|
||||
}
|
||||
Game::Lua::SetTop(L, fi); // drop arg1, keep frame
|
||||
|
||||
const char *suffix = ButtonSuffix(btn);
|
||||
char prefix[24];
|
||||
BuildModifierPrefix(L, prefix, sizeof prefix);
|
||||
|
||||
char verb[32];
|
||||
if (ReadModAttr(L, fi, prefix, "type", suffix, verb, sizeof verb)) {
|
||||
char unit[128];
|
||||
const bool haveUnit =
|
||||
ReadModAttr(L, fi, prefix, "unit", suffix, unit, sizeof unit);
|
||||
handled = DispatchVerb(L, fi, prefix, suffix, verb,
|
||||
haveUnit ? unit : nullptr);
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
if (!handled)
|
||||
ChainOld(L); // let the frame's own OnClick handle unconfigured clicks
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Installs OnClick as a closure over (previous handler, frame). Operates on
|
||||
// self at stack index 1. Raises if `self` isn't a Button (OnClick is a Button
|
||||
// script) — callers run it under PCall.
|
||||
//
|
||||
// Self-healing against clobbering: addons re-`SetScript("OnClick", …)` their
|
||||
// own handler (pfUI does it on every raid relayout via UpdateScripts), which
|
||||
// replaces our closure. So we re-wire whenever a `type*` attribute is (re)set,
|
||||
// but only when the current OnClick is a *Lua* handler (a real clobber) or nil
|
||||
// — if it's already a C function it's our own closure, and re-wrapping would
|
||||
// double-chain. `IsCFunction` cleanly distinguishes the two here (addon click
|
||||
// handlers are Lua; the only C-function OnClick we ever see is ours).
|
||||
int __fastcall WireOnClick(void *L) {
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
Game::Lua::PushString(L, "OnClick"); // (self, name)
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_GETSCRIPT, L); // (self, name, old)
|
||||
if (Game::Lua::GetTop(L) < 3) // defensive
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::SetTop(L, 3); // (self, name, old|nil)
|
||||
if (Game::Lua::Type(L, 3) == Game::Lua::TYPE_FUNCTION &&
|
||||
Game::Lua::IsCFunction(L, 3) != 0)
|
||||
return 0; // already our closure
|
||||
Game::Lua::PushValue(L, 1); // (self, name, old, self)
|
||||
Game::Lua::PushCClosure(L, &OnClick_c, 2); // upvalues (old, self)
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_SETSCRIPT, L);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- OnAttributeChanged script (settable via SetScript, like retail) --------
|
||||
//
|
||||
// Backport of the modern `OnAttributeChanged` widget script: SetAttribute (but
|
||||
// NOT SetAttributeNoHandler) fires the frame's OnAttributeChanged handler. 1.12
|
||||
// has no such script, so we make it SetScript/GetScript/HookScript-able the same
|
||||
// way Tooltip::SetEvents adds OnTooltipSet* — co-hook the frame script-name
|
||||
// resolver (FUN_FRAME_SCRIPT_RESOLVER, the base every frame type delegates to)
|
||||
// and, for the one name the engine doesn't know, hand back an external 8-byte
|
||||
// {handler, context} cell. Frames are immortal in 1.12 (no destroy), so a cell
|
||||
// keyed by the frame's C object pointer never goes stale.
|
||||
//
|
||||
// 1.12 passes script args as globals, not params (see OnClick_c reading arg1),
|
||||
// so we fire with `this` = frame (bound by the invoker), `arg1` = name and
|
||||
// `arg2` = the new value: `function() local name, value = arg1, arg2 … end`.
|
||||
|
||||
struct AttrScriptSlot { uint32_t v[2]; }; // {handler, exec context}
|
||||
std::unordered_map<const void *, AttrScriptSlot> g_attrHandlers;
|
||||
|
||||
uint32_t *AttrSlotFor(const void *frame, bool create) {
|
||||
auto it = g_attrHandlers.find(frame);
|
||||
if (it != g_attrHandlers.end())
|
||||
return it->second.v;
|
||||
if (!create)
|
||||
return nullptr;
|
||||
AttrScriptSlot &s = g_attrHandlers[frame]; // node-based → stable address
|
||||
s.v[0] = 0;
|
||||
s.v[1] = 0;
|
||||
return s.v;
|
||||
}
|
||||
|
||||
// Resolver co-hook. The engine's __thiscall(frame, name) is modelled as
|
||||
// __fastcall with a dummy edx — `name` lands on the first stack slot either way
|
||||
// (same trick as Tooltip::SetEvents). The original returns 0 for a name it
|
||||
// doesn't know; we then claim "OnAttributeChanged".
|
||||
using FrameResolver_t = int(__fastcall *)(void *frame, void *edx, const char *name);
|
||||
FrameResolver_t g_frameResolverOriginal = nullptr;
|
||||
|
||||
int __fastcall FrameResolver_h(void *frame, void *edx, const char *name) {
|
||||
const int slot = g_frameResolverOriginal(frame, edx, name);
|
||||
if (slot != 0) // a real base-frame / subtype script — leave it
|
||||
return slot;
|
||||
if (EqI(name, "onattributechanged"))
|
||||
return reinterpret_cast<int>(AttrSlotFor(frame, /*create*/ true));
|
||||
return 0;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _attrResolverHook{
|
||||
Offsets::FUN_FRAME_SCRIPT_RESOLVER,
|
||||
reinterpret_cast<void *>(&FrameResolver_h),
|
||||
reinterpret_cast<void **>(&g_frameResolverOriginal)};
|
||||
|
||||
// Fire OnAttributeChanged on `frame`, with arg1 = name and arg2 = the new value
|
||||
// (still at stack[3] in DoSet). Recursion-guarded so a handler that itself calls
|
||||
// SetAttribute doesn't loop. The invoker (FUN_FRAME_INVOKE_SCRIPT) binds `this`
|
||||
// and runs the handler under its own protected pcall; it doesn't restore the
|
||||
// stack top, so snapshot/restore around it (as Tooltip::SetEvents does).
|
||||
int g_firingAttr = 0;
|
||||
|
||||
using InvokeAttrScript_t = void(__fastcall *)(uint32_t handler, void *frame);
|
||||
|
||||
void FireAttributeChanged(void *L, void *frame, const char *name) {
|
||||
if (frame == nullptr || g_firingAttr > 0)
|
||||
return;
|
||||
uint32_t *slot = AttrSlotFor(frame, /*create*/ false);
|
||||
if (slot == nullptr || slot[0] == 0)
|
||||
return;
|
||||
|
||||
const int savedTop = Game::Lua::GetTop(L);
|
||||
|
||||
// Snapshot the caller's arg1/arg2 first. SetAttribute can be called from
|
||||
// inside a running script handler that still needs them, and the invoker
|
||||
// below neither saves the globals nor restores the stack top — so we save
|
||||
// (onto the stack, below anything the invoker pushes), overwrite, fire,
|
||||
// then put them back. 1.12 passes handler args as globals, not params.
|
||||
Game::Lua::PushString(L, "arg1");
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [.., oldArg1]
|
||||
Game::Lua::PushString(L, "arg2");
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [.., oldArg1, oldArg2]
|
||||
const int oldArg1 = savedTop + 1;
|
||||
const int oldArg2 = savedTop + 2;
|
||||
|
||||
Game::Lua::PushString(L, "arg1");
|
||||
Game::Lua::PushString(L, name);
|
||||
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX); // _G.arg1 = name
|
||||
Game::Lua::PushString(L, "arg2");
|
||||
if (savedTop >= 3)
|
||||
Game::Lua::PushValue(L, 3); // the new value
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX); // _G.arg2 = value
|
||||
|
||||
++g_firingAttr;
|
||||
reinterpret_cast<InvokeAttrScript_t>(Offsets::FUN_FRAME_INVOKE_SCRIPT)(slot[0], frame);
|
||||
--g_firingAttr;
|
||||
|
||||
// Restore arg1/arg2 from the snapshot (the fired handler may also have
|
||||
// written them; the caller's values win).
|
||||
Game::Lua::PushString(L, "arg1");
|
||||
Game::Lua::PushValue(L, oldArg1);
|
||||
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX);
|
||||
Game::Lua::PushString(L, "arg2");
|
||||
Game::Lua::PushValue(L, oldArg2);
|
||||
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX);
|
||||
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
}
|
||||
|
||||
// ---- the methods -----------------------------------------------------------
|
||||
|
||||
// Enables the frame's mouse so it can become the mouse-focus (a bare frame
|
||||
// otherwise never registers as hovered). Operates on self at index 1.
|
||||
void EnableFrameMouse(void *L) {
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
Game::Lua::PushBoolean(L, 1);
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_ENABLEMOUSE, L);
|
||||
}
|
||||
|
||||
int DoSet(void *L, bool fireHandler) { // (self, name, value)
|
||||
if (!Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: frame:SetAttribute(\"name\", value)");
|
||||
return 0;
|
||||
}
|
||||
char lname[256];
|
||||
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
|
||||
|
||||
if (PushSub(L, 1, true)) { // [.., sub]
|
||||
const int si = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, lname); // [.., sub, key]
|
||||
Game::Lua::PushValue(L, 3); // [.., sub, key, value] (nil if absent)
|
||||
Game::Lua::RawSet(L, si); // sub[lname] = value
|
||||
Game::Lua::SetTop(L, si - 1); // drop sub
|
||||
}
|
||||
|
||||
const bool isString = Game::Lua::Type(L, 3) == Game::Lua::TYPE_STRING;
|
||||
|
||||
// `unit` drives the mouseover binding: index it by the frame's C object
|
||||
// (the key the mouse-focus poll uses) and enable its mouse so it can be
|
||||
// hovered.
|
||||
if (std::strcmp(lname, "unit") == 0) {
|
||||
void *obj = Game::Lua::ResolveObject(L, 1);
|
||||
if (obj != nullptr) {
|
||||
if (isString) {
|
||||
const char *tok = Game::Lua::ToString(L, 3);
|
||||
const bool isNew = g_unitByFrame.find(obj) == g_unitByFrame.end();
|
||||
g_unitByFrame[obj] = tok ? tok : "";
|
||||
if (isNew)
|
||||
EnableFrameMouse(L);
|
||||
} else {
|
||||
g_unitByFrame.erase(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A `type*` attribute makes the frame a clickable unit button: (re)install
|
||||
// the chained OnClick (PCall-guarded — OnClick is Button-only). WireOnClick
|
||||
// is idempotent and self-healing, so calling it on every `type*` set both
|
||||
// avoids double-chaining and reinstalls after an addon clobbered our
|
||||
// OnClick — set `type*` again after re-SetScript to recover. The handler
|
||||
// reads all the attributes fresh at click time. IsTypeAttr covers every
|
||||
// form the resolver reads (type, typeN, modifier-prefixed), so a middle-
|
||||
// click or modifier-only binding still wires.
|
||||
else if (IsTypeAttr(lname) && isString) {
|
||||
Game::Lua::PushCClosure(L, &WireOnClick, 0);
|
||||
Game::Lua::PushValue(L, 1); // arg = self
|
||||
Game::Lua::PCall(L, 1, 0, 0); // ignore errors (non-Button frame)
|
||||
}
|
||||
|
||||
if (fireHandler)
|
||||
FireAttributeChanged(L, Game::Lua::ResolveObject(L, 1), lname);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_SetAttribute(void *L) { return DoSet(L, /*fireHandler*/ true); }
|
||||
int __fastcall Script_SetAttributeNoHandler(void *L) { return DoSet(L, /*fireHandler*/ false); }
|
||||
|
||||
// `cleared = frame:ClearAttribute("name")` — retail (11.2.0) attribute removal;
|
||||
// `cleared` is true iff the attribute was set (and is now gone). Routes through
|
||||
// DoSet's nil-value path so it also drops a `unit` mouseover binding, but does
|
||||
// NOT fire OnAttributeChanged — verified against retail (a live 12.0 test showed
|
||||
// ClearAttribute firing no handler, unlike SetAttribute).
|
||||
int __fastcall Script_ClearAttribute(void *L) {
|
||||
if (!Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: frame:ClearAttribute(\"name\")");
|
||||
return 0;
|
||||
}
|
||||
char lname[256];
|
||||
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
|
||||
|
||||
const bool existed = TryPushValue(L, 1, lname); // leaves the value on top if set
|
||||
if (existed)
|
||||
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1); // drop the peeked value
|
||||
|
||||
Game::Lua::SetTop(L, 2); // (self, name)
|
||||
Game::Lua::PushNil(L); // (self, name, nil) → value = nil clears the key
|
||||
DoSet(L, /*fireHandler*/ false); // retail ClearAttribute fires no OnAttributeChanged
|
||||
|
||||
Game::Lua::PushBool(L, existed);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetAttribute(void *L) {
|
||||
if (!Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: frame:GetAttribute(\"name\")");
|
||||
return 0;
|
||||
}
|
||||
// 3-arg form: (prefix, name, suffix) with wildcard precedence.
|
||||
if (Game::Lua::GetTop(L) >= 4 && Game::Lua::IsString(L, 3)) {
|
||||
const char *pfx = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : "";
|
||||
const char *nm = Game::Lua::ToString(L, 3);
|
||||
const char *sfx = Game::Lua::IsString(L, 4) ? Game::Lua::ToString(L, 4) : "";
|
||||
char key[256];
|
||||
Compose3Lower(key, sizeof key, pfx, nm, sfx);
|
||||
if (TryPushValue(L, 1, key)) return 1;
|
||||
Compose3Lower(key, sizeof key, "*", nm, sfx);
|
||||
if (TryPushValue(L, 1, key)) return 1;
|
||||
Compose3Lower(key, sizeof key, pfx, nm, "*");
|
||||
if (TryPushValue(L, 1, key)) return 1;
|
||||
Compose3Lower(key, sizeof key, "*", nm, "*");
|
||||
if (TryPushValue(L, 1, key)) return 1;
|
||||
Compose3Lower(key, sizeof key, "", nm, "");
|
||||
if (TryPushValue(L, 1, key)) return 1;
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
char lname[256];
|
||||
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
|
||||
if (TryPushValue(L, 1, lname))
|
||||
return 1;
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ---- registration ----------------------------------------------------------
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
|
||||
{"SetAttribute", &Script_SetAttribute},
|
||||
{"SetAttributeNoHandler", &Script_SetAttributeNoHandler},
|
||||
{"ClearAttribute", &Script_ClearAttribute},
|
||||
{"GetAttribute", &Script_GetAttribute},
|
||||
};
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_FRAME_METHOD_REGISTRY),
|
||||
g_frameMethods,
|
||||
static_cast<int>(sizeof(g_frameMethods) / sizeof(g_frameMethods[0])));
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
const Tick::WorldTick::AutoSubscribe _tick{&MouseoverTick};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Frame::Attributes
|
||||
@@ -0,0 +1,295 @@
|
||||
// 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/>.
|
||||
|
||||
// Modern positional script arguments — make every frame-script handler
|
||||
// receive `(self, arg1..argN)` the way later clients do, on top of the
|
||||
// vanilla `this`/`arg1`/`event` globals.
|
||||
//
|
||||
// frame:SetScript("OnMouseWheel", function() print(this, arg1) end) -- vanilla
|
||||
// frame:SetScript("OnMouseWheel", function(self, delta) print(self, delta) end) -- modern
|
||||
//
|
||||
// Vanilla ALWAYS invokes a script handler with ZERO Lua arguments: the two
|
||||
// runners the engine funnels every dispatch through — FUN_FRAME_INVOKE_SCRIPT
|
||||
// (no-arg scripts) and FUN_FRAME_RUN_SCRIPT_ARGS (scripts with values) — set
|
||||
// the `this`/`arg1..argN` globals and then `lua_pcall(handler, /*nargs*/0)`.
|
||||
// So `function(self, delta)` gets nothing today.
|
||||
//
|
||||
// We co-hook both runners and REPLACE their pcall tail: keep setting the same
|
||||
// globals (so vanilla handlers reading `this`/`arg1` keep working — this is
|
||||
// purely additive; Lua silently drops the extra positional args a zero-param
|
||||
// handler doesn't declare), but push `self` + `arg1..argN` as real arguments
|
||||
// and `pcall` with `nargs = 1 + N`. The reimplementation mirrors the engine's
|
||||
// exact global save/set/restore and its message-handler errfunc; only the
|
||||
// final push+pcall differs.
|
||||
//
|
||||
// Convention: `(self, arg1..argN)` for every script, plus `event` inserted as
|
||||
// the first positional for OnEvent — `(self, event, arg1..argN)` — matching
|
||||
// retail (and Frame::Modern's HookScript). An OnEvent dispatch is detected
|
||||
// structurally at the runner: the handler ref being invoked equals the frame's
|
||||
// OnEvent slot handler (frame+OFF_FRAME_ONEVENT_SLOT). This is exact — each
|
||||
// SetScript creates a distinct ref, so a function bound to two scripts still
|
||||
// differs per slot — so no fragile "are we mid event-dispatch" flag is needed.
|
||||
//
|
||||
// Runtime switch, default OFF (opt-in): `SetModernScriptArgs(true)` enables it,
|
||||
// `GetModernScriptArgs()` reads the state. While disabled both detours are a
|
||||
// straight passthrough to the original runner (zero reimplementation risk).
|
||||
// Opt-in because this lands on the hottest Lua path in the engine
|
||||
// (FUN_FRAME_RUN_SCRIPT_ARGS fires for every OnUpdate frame).
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Frame::ScriptArgs {
|
||||
|
||||
namespace {
|
||||
|
||||
// Runtime switch. Default OFF (opt-in). When false, both detours tail-call the
|
||||
// original runner unchanged (exact vanilla behavior, no reimplementation risk).
|
||||
bool g_enabled = false;
|
||||
|
||||
// Engine cap: the runner stops setting arg globals at index 19 (arg1..arg19).
|
||||
constexpr int kMaxArgs = 19;
|
||||
|
||||
// --- raw engine primitives not exposed via Game::Lua -----------------------
|
||||
// lua_rawgeti(L, idx, n) — push table_at_idx[n]. Used with idx = REGISTRY to
|
||||
// push a value stored under an integer ref (handler / frame object / message
|
||||
// handler / luaL_ref'd saved globals).
|
||||
using RawGetI_t = void(__fastcall *)(void *L, int idx, int ref);
|
||||
// luaL_ref(L, t) → int — pop the top, store it under a fresh int key in table
|
||||
// `t`, return the key. luaL_unref frees it.
|
||||
using LuaLRef_t = int(__fastcall *)(void *L, int t);
|
||||
using LuaLUnref_t = void(__fastcall *)(void *L, int t, int ref);
|
||||
// FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER — __thiscall(frame, nameOrNull);
|
||||
// lazily materializes the frame's Lua-registry ref at frame+OFF_COBJECT_LUA_REF.
|
||||
using ScriptRegister_t = void(__thiscall *)(void *frame, void *nameOrNull);
|
||||
|
||||
const auto RawGetI = reinterpret_cast<RawGetI_t>(Offsets::LUA_RAWGETI);
|
||||
const auto LuaLRef = reinterpret_cast<LuaLRef_t>(Offsets::LUA_REF_REF);
|
||||
const auto LuaLUnref = reinterpret_cast<LuaLUnref_t>(Offsets::LUA_REF_UNREF);
|
||||
|
||||
// Returns the frame's Lua object ref (frame+0x08), lazily creating it exactly
|
||||
// as the engine runner does (`if (refcount == 0) ScriptRegister(frame, 0)`).
|
||||
int EnsureLuaRef(void *frame) {
|
||||
auto *f = reinterpret_cast<uint8_t *>(frame);
|
||||
if (*reinterpret_cast<int *>(f + Offsets::OFF_COBJECT_LUA_REFCOUNT) == 0)
|
||||
reinterpret_cast<ScriptRegister_t>(
|
||||
Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER)(frame, nullptr);
|
||||
return *reinterpret_cast<int *>(f + Offsets::OFF_COBJECT_LUA_REF);
|
||||
}
|
||||
|
||||
// Writes "arg" + k (k in 1..19) into buf; buf must hold at least 6 bytes.
|
||||
void ArgName(char *buf, int k) {
|
||||
buf[0] = 'a';
|
||||
buf[1] = 'r';
|
||||
buf[2] = 'g';
|
||||
if (k < 10) {
|
||||
buf[3] = static_cast<char>('0' + k);
|
||||
buf[4] = '\0';
|
||||
} else {
|
||||
buf[3] = static_cast<char>('0' + k / 10);
|
||||
buf[4] = static_cast<char>('0' + k % 10);
|
||||
buf[5] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
// Sets _G[name] = <value currently on the stack top>, capturing the previous
|
||||
// _G[name] into *outOldRef (a luaL_ref for later restore). Stack-neutral:
|
||||
// consumes the value, leaves the stack as it was minus that value.
|
||||
void SetGlobalSaving(void *L, const char *name, int *outOldRef) {
|
||||
using namespace Game::Lua;
|
||||
PushString(L, name); // [.., newVal, name]
|
||||
GetTable(L, GLOBALS_INDEX); // [.., newVal, oldVal]
|
||||
*outOldRef = LuaLRef(L, REGISTRY_INDEX); // [.., newVal]
|
||||
PushString(L, name); // [.., newVal, name]
|
||||
Insert(L, -2); // [.., name, newVal]
|
||||
SetTable(L, GLOBALS_INDEX); // [..]
|
||||
}
|
||||
|
||||
// Restores _G[name] to the value at oldRef, then frees the ref.
|
||||
void RestoreGlobal(void *L, const char *name, int oldRef) {
|
||||
using namespace Game::Lua;
|
||||
RawGetI(L, REGISTRY_INDEX, oldRef); // [.., oldVal]
|
||||
PushString(L, name); // [.., oldVal, name]
|
||||
Insert(L, -2); // [.., name, oldVal]
|
||||
SetTable(L, GLOBALS_INDEX); // [..]
|
||||
LuaLUnref(L, REGISTRY_INDEX, oldRef);
|
||||
}
|
||||
|
||||
// Reimplements the runner tail: set globals (with save/restore), then invoke
|
||||
// the handler with modern positional args `(self, arg1..argN)` under the
|
||||
// engine's own message-handler errfunc. `fmt`/`vaPtr` are null for the no-arg
|
||||
// runner (N == 0).
|
||||
void RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr) {
|
||||
using namespace Game::Lua;
|
||||
void *L = State();
|
||||
if (L == nullptr)
|
||||
return;
|
||||
|
||||
// --- _G.this = frame -----------------------------------------------------
|
||||
const bool haveThis = frame != nullptr;
|
||||
// OnEvent dispatch? The event dispatchers invoke the frame's OnEvent slot
|
||||
// (frame+0x0C); matching the handler ref against that slot identifies it
|
||||
// exactly. When so, `event` (a global the dispatcher has set) is prepended
|
||||
// as the first positional to mirror retail's (self, event, arg1..argN).
|
||||
const bool isOnEvent =
|
||||
haveThis &&
|
||||
*reinterpret_cast<const int *>(reinterpret_cast<uint8_t *>(frame) +
|
||||
Offsets::OFF_FRAME_ONEVENT_SLOT) ==
|
||||
handlerRef;
|
||||
int selfRef = 0;
|
||||
int oldThisRef = 0;
|
||||
if (haveThis) {
|
||||
selfRef = EnsureLuaRef(frame);
|
||||
RawGetI(L, REGISTRY_INDEX, selfRef); // push frame object
|
||||
SetGlobalSaving(L, "this", &oldThisRef);
|
||||
}
|
||||
|
||||
// --- _G.arg1..argN from the format --------------------------------------
|
||||
int oldArgRefs[kMaxArgs];
|
||||
char argNames[kMaxArgs][6];
|
||||
int n = 0;
|
||||
if (fmt != nullptr) {
|
||||
const char *cur = static_cast<const char *>(vaPtr);
|
||||
for (const char *p = fmt; *p != '\0' && n < kMaxArgs; ++p) {
|
||||
if (*p != '%')
|
||||
continue;
|
||||
++p;
|
||||
if (*p == '\0')
|
||||
break;
|
||||
bool consumed = true;
|
||||
switch (*p) {
|
||||
case 'd':
|
||||
PushNumber(L, static_cast<double>(*reinterpret_cast<const int *>(cur)));
|
||||
cur += 4;
|
||||
break;
|
||||
case 'u':
|
||||
PushNumber(L, static_cast<double>(
|
||||
*reinterpret_cast<const unsigned int *>(cur)));
|
||||
cur += 4;
|
||||
break;
|
||||
case 'f':
|
||||
PushNumber(L, *reinterpret_cast<const double *>(cur));
|
||||
cur += 8;
|
||||
break;
|
||||
case 's':
|
||||
PushString(L, *reinterpret_cast<const char *const *>(cur));
|
||||
cur += 4;
|
||||
break;
|
||||
default:
|
||||
consumed = false; // unknown spec — skip, consume nothing
|
||||
break;
|
||||
}
|
||||
if (!consumed)
|
||||
continue;
|
||||
ArgName(argNames[n], n + 1);
|
||||
SetGlobalSaving(L, argNames[n], &oldArgRefs[n]);
|
||||
++n;
|
||||
}
|
||||
}
|
||||
|
||||
// --- call handler(self, arg1..argN) -------------------------------------
|
||||
const int base = GetTop(L);
|
||||
const int errRef = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_FRAMESCRIPT_ERROR_HANDLER_REF));
|
||||
int errIdx = 0;
|
||||
if (errRef > 0) {
|
||||
RawGetI(L, REGISTRY_INDEX, errRef); // message handler
|
||||
errIdx = base + 1; // absolute index of the errfunc
|
||||
}
|
||||
RawGetI(L, REGISTRY_INDEX, handlerRef); // handler
|
||||
if (haveThis)
|
||||
RawGetI(L, REGISTRY_INDEX, selfRef); // self
|
||||
else
|
||||
PushNil(L);
|
||||
if (isOnEvent) { // event goes before the args, retail-style
|
||||
PushString(L, "event");
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
}
|
||||
for (int k = 0; k < n; ++k) { // positional args, re-read from the globals
|
||||
PushString(L, argNames[k]);
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
}
|
||||
PCall(L, 1 + (isOnEvent ? 1 : 0) + n, 0, errIdx);
|
||||
SetTop(L, base); // drop the message handler + any leftover error object
|
||||
|
||||
// --- restore globals (reverse order) ------------------------------------
|
||||
for (int k = n - 1; k >= 0; --k)
|
||||
RestoreGlobal(L, argNames[k], oldArgRefs[k]);
|
||||
if (haveThis)
|
||||
RestoreGlobal(L, "this", oldThisRef);
|
||||
}
|
||||
|
||||
// --- FUN_FRAME_RUN_SCRIPT_ARGS co-hook (scripts with values) ----------------
|
||||
using RunArgs_t = void(__cdecl *)(int handlerRef, void *frame, const char *fmt,
|
||||
const void *vaPtr);
|
||||
RunArgs_t g_origRunArgs = nullptr;
|
||||
|
||||
void __cdecl RunArgs_h(int handlerRef, void *frame, const char *fmt,
|
||||
const void *vaPtr) {
|
||||
if (!g_enabled || handlerRef == 0 || fmt == nullptr) {
|
||||
g_origRunArgs(handlerRef, frame, fmt, vaPtr);
|
||||
return;
|
||||
}
|
||||
RunModern(handlerRef, frame, fmt, vaPtr);
|
||||
}
|
||||
|
||||
// --- FUN_FRAME_INVOKE_SCRIPT co-hook (no-arg scripts) -----------------------
|
||||
// Same address Tooltip::SetEvents calls directly to fire OnTooltipSet*; with
|
||||
// the switch enabled those fire with (self) too, which is the modern shape.
|
||||
using Invoke_t = void(__fastcall *)(int handlerRef, void *frame);
|
||||
Invoke_t g_origInvoke = nullptr;
|
||||
|
||||
void __fastcall Invoke_h(int handlerRef, void *frame) {
|
||||
if (!g_enabled || handlerRef == 0 || frame == nullptr) {
|
||||
g_origInvoke(handlerRef, frame);
|
||||
return;
|
||||
}
|
||||
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _runArgsHook{
|
||||
Offsets::FUN_FRAME_RUN_SCRIPT_ARGS,
|
||||
reinterpret_cast<void *>(&RunArgs_h),
|
||||
reinterpret_cast<void **>(&g_origRunArgs)};
|
||||
|
||||
const Game::HookAutoRegister _invokeHook{
|
||||
Offsets::FUN_FRAME_INVOKE_SCRIPT,
|
||||
reinterpret_cast<void *>(&Invoke_h),
|
||||
reinterpret_cast<void **>(&g_origInvoke)};
|
||||
|
||||
// --- Lua toggle -------------------------------------------------------------
|
||||
int __fastcall Script_SetModernScriptArgs(void *L) {
|
||||
g_enabled = Game::Lua::ToBoolean(L, 1) != 0;
|
||||
Game::Lua::PushBool(L, g_enabled);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetModernScriptArgs(void *L) {
|
||||
Game::Lua::PushBool(L, g_enabled);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("SetModernScriptArgs",
|
||||
&Script_SetModernScriptArgs);
|
||||
Game::Lua::RegisterGlobalFunction("GetModernScriptArgs",
|
||||
&Script_GetModernScriptArgs);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Frame::ScriptArgs
|
||||
@@ -110,6 +110,8 @@ constexpr uint32_t kSettleMs = 1500;
|
||||
// ~currently-owned items rather than everything ever owned.
|
||||
constexpr int kMaxMissStreak = 4;
|
||||
|
||||
constexpr int kMaxContainerSlots = 64;
|
||||
|
||||
// One seen-set entry: an owned item's GUID and how many consecutive scans it
|
||||
// has been absent from the read (0 = present this scan).
|
||||
struct SeenEntry {
|
||||
@@ -313,7 +315,9 @@ void AppendNonBagOwned(uint64_t *out, int *count) {
|
||||
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
|
||||
if (bagInvMgr == nullptr)
|
||||
continue;
|
||||
const int bagSlots = static_cast<int>(*reinterpret_cast<const uint32_t *>(bagInvMgr));
|
||||
int bagSlots = static_cast<int>(*reinterpret_cast<const uint32_t *>(bagInvMgr));
|
||||
if (bagSlots > kMaxContainerSlots)
|
||||
bagSlots = kMaxContainerSlots; // guard against a garbage count at login
|
||||
if (bagSlots > 0)
|
||||
AppendGuidArray(bagInvMgr, 0, bagSlots - 1, out, count);
|
||||
}
|
||||
|
||||
+123
-37
@@ -8,16 +8,27 @@
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// Hook-driven loot scanner. Walks the nearby-lootable-units set, opens
|
||||
// each corpse's loot session via `FUN_CMSG_LOOT_UNIT`, then *intercepts
|
||||
// the engine's loot-window response* before any visual updates happen.
|
||||
// The intercept (`FUN_LOOT_CONTROLLER`) lets the original function run
|
||||
// so `VAR_LOOT_SLOTS` populates and the link builder works — but a
|
||||
// Hook-driven loot corpse-walker. Walks the nearby-lootable-units set,
|
||||
// opens each corpse's loot session via `FUN_CMSG_LOOT_UNIT`, then
|
||||
// *intercepts the engine's loot-window response* before any visual updates
|
||||
// happen. The intercept (`FUN_LOOT_CONTROLLER`) lets the original function
|
||||
// run so `VAR_LOOT_SLOTS` populates and the link builder works — but a
|
||||
// paired hook on `FUN_FIRE_EVENT_NO_ARGS` swallows the `LOOT_OPENED` /
|
||||
// `LOOT_CLOSED` event fires while the scan is in progress. Net result:
|
||||
// the engine's state machine cycles silently for each corpse and
|
||||
// `LootFrame` never reacts. Other Lua addons listening to those events
|
||||
// also see nothing during the scan; that's deliberate.
|
||||
// `LOOT_CLOSED` event fires while the walk is in progress. Net result: the
|
||||
// engine's state machine cycles silently for each corpse and `LootFrame`
|
||||
// never reacts. Other Lua addons listening to those events also see nothing
|
||||
// during the walk; that's deliberate.
|
||||
//
|
||||
// Two Lua entry points share this one walker (and, importantly, this one
|
||||
// `FUN_LOOT_CONTROLLER` hook — MinHook allows a single hook per target, so
|
||||
// they can't be separate modules):
|
||||
// - `C_Loot.ScanNearbyLoot()` — scrape-only: read each corpse's contents
|
||||
// into `GetLastScanResults()` and release without looting.
|
||||
// - `C_Loot.LootAllCorpses([max])` — the same walk but actually LOOTS
|
||||
// each window (coin via `CMSG_LOOT_MONEY`, every item slot via
|
||||
// `CMSG_LOOT_ITEM`) before releasing. Silent (no LootFrame flicker);
|
||||
// the usual item-received / money chat + inventory events still fire,
|
||||
// since only the LootFrame open/close events are swallowed.
|
||||
//
|
||||
// Compared to the older polling-driven version this also collapses the
|
||||
// state machine — we no longer need `WaitingOpen` / `WaitingClose` /
|
||||
@@ -79,6 +90,7 @@ struct ScanCtx {
|
||||
uint64_t currentGuid = 0;
|
||||
int ticksSinceTransition = 0;
|
||||
bool suppressEvents = false; // read by the FireEvent hook
|
||||
bool lootMode = false; // LootAllCorpses (true) vs ScanNearbyLoot (false)
|
||||
std::vector<uint64_t> queue;
|
||||
std::vector<LootEntry> results;
|
||||
};
|
||||
@@ -106,6 +118,12 @@ using CloseLootInner_t = void(__fastcall *)(int sendRelease,
|
||||
using LootSlotLinkBuilder_t = const char *(__fastcall *)(uint32_t userFacingSlot,
|
||||
char *outBuf,
|
||||
int bufSize);
|
||||
// `FUN_CMSG_LOOT_ITEM` — `__stdcall(uint8_t wireSlot)`, sends CMSG_LOOT_ITEM
|
||||
// for one slot of the open window (see the offset comment for the
|
||||
// __stdcall-vs-__fastcall stack-corruption hazard).
|
||||
using SendLootItem_t = void(__stdcall *)(uint8_t wireSlot);
|
||||
// `FUN_CMSG_LOOT_MONEY` — `__fastcall(player)`, sends the coin request.
|
||||
using LootMoney_t = void(__fastcall *)(void *player);
|
||||
// `FUN_LOOT_CONTROLLER` is `__fastcall(int, undefined4, int)` — three
|
||||
// args: lootStructPtr in ECX, coin in EDX, an extra unk on stack[0].
|
||||
// Mis-declaring as 4-arg __fastcall would push one phantom stack slot
|
||||
@@ -207,6 +225,42 @@ void ScrapeCurrentLoot(LootEntry *out) {
|
||||
}
|
||||
}
|
||||
|
||||
// Loots everything in the currently-open window: coin (if any) plus every
|
||||
// populated item slot. Called from the controller hook (loot mode) after the
|
||||
// original populated `VAR_LOOT_SLOTS`, so the slot table is fully filled in.
|
||||
//
|
||||
// Items go through `FUN_CMSG_LOOT_ITEM` directly — the same silent path
|
||||
// `C_Loot.LootUnitItem` uses, which bypasses the BoP/unique confirm dialog
|
||||
// the Lua `LootSlot` wrapper would raise (appropriate for a programmatic
|
||||
// loot-all; the server still enforces real permissions). Coin goes through
|
||||
// `FUN_CMSG_LOOT_MONEY` with `s_scan.player` — the SAME pointer we passed to
|
||||
// `FUN_CMSG_LOOT_UNIT`, whose send wrote this window's target GUID to the
|
||||
// player's `+0x1D28` guard slot, so the money send's guard passes.
|
||||
//
|
||||
// Packet order to the server is items, then coin, then the release that
|
||||
// follows (`SendCloseLoot`); the server processes loot-item / loot-money
|
||||
// before the release, so nothing is dropped.
|
||||
void LootCurrentWindow() {
|
||||
// Coin present when `VAR_LOOT_LOOTABLE` holds a real amount (0 = none;
|
||||
// 0xFFFFFFFF = already-looted sentinel, never seen on a fresh open).
|
||||
const uint32_t coinRaw = *reinterpret_cast<const uint32_t *>(
|
||||
Offsets::VAR_LOOT_LOOTABLE);
|
||||
if (coinRaw != 0 && coinRaw != 0xFFFFFFFFu) {
|
||||
auto LootMoney = reinterpret_cast<LootMoney_t>(Offsets::FUN_CMSG_LOOT_MONEY);
|
||||
LootMoney(s_scan.player);
|
||||
}
|
||||
|
||||
auto SendItem = reinterpret_cast<SendLootItem_t>(Offsets::FUN_CMSG_LOOT_ITEM);
|
||||
for (int slot = 0; slot < Offsets::LOOT_MAX_SLOTS; ++slot) {
|
||||
auto *entry = reinterpret_cast<const uint8_t *>(
|
||||
Offsets::VAR_LOOT_SLOTS + slot * Offsets::LOOT_SLOT_STRIDE);
|
||||
const uint32_t itemID = *reinterpret_cast<const uint32_t *>(entry);
|
||||
if (itemID == 0)
|
||||
continue;
|
||||
SendItem(*(entry + Offsets::OFF_LOOT_SLOT_WIRE_INDEX));
|
||||
}
|
||||
}
|
||||
|
||||
// === State-machine transitions ===
|
||||
|
||||
void StartLoot(uint64_t guid);
|
||||
@@ -221,6 +275,7 @@ void Complete() {
|
||||
s_scan.state = State::Idle;
|
||||
s_scan.currentGuid = 0;
|
||||
s_scan.suppressEvents = false;
|
||||
s_scan.lootMode = false;
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEventCompleted), "");
|
||||
}
|
||||
|
||||
@@ -315,6 +370,12 @@ void __fastcall LootController_h(int lootStructPtr, int coin, int unk) {
|
||||
ScrapeCurrentLoot(&entry);
|
||||
s_scan.results.push_back(std::move(entry));
|
||||
|
||||
// Loot mode: actually take everything (coin + all slots) before the
|
||||
// release below. Scrape ran first, so `GetLastScanResults()` still
|
||||
// reports what each corpse held.
|
||||
if (s_scan.lootMode)
|
||||
LootCurrentWindow();
|
||||
|
||||
// Sends `CMSG_LOOT_RELEASE` and recursively calls this hook
|
||||
// with `arg1 == 0`. That recursive call falls into the
|
||||
// passthrough branch but still sees `suppressEvents == true`
|
||||
@@ -377,46 +438,69 @@ int __fastcall EnumCallback(EnumCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
void BuildQueue(void *player) {
|
||||
void BuildQueue(void *player, size_t maxCount) {
|
||||
s_scan.queue.clear();
|
||||
auto Enum = reinterpret_cast<ClntObjMgrEnumVisibleObjects_t>(
|
||||
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
|
||||
EnumCtx ctx{player, &s_scan.queue};
|
||||
Enum(reinterpret_cast<ClntObjMgrEnumVisibleObjectsCallback_t>(&EnumCallback),
|
||||
&ctx);
|
||||
// Cap to `maxCount` corpses (0 = unlimited). Order is engine
|
||||
// enumeration order, not distance-sorted, so the truncation is
|
||||
// arbitrary among in-range corpses.
|
||||
if (maxCount != 0 && s_scan.queue.size() > maxCount)
|
||||
s_scan.queue.resize(maxCount);
|
||||
}
|
||||
|
||||
// Shared starter for both entry points: resolve the player, build the
|
||||
// corpse queue, kick off the walk. Returns whether it started — `false` if a
|
||||
// walk is already in progress or there's no local player.
|
||||
bool BeginWalk(bool lootMode, size_t maxCount) {
|
||||
if (s_scan.state != State::Idle)
|
||||
return false;
|
||||
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr)
|
||||
return false;
|
||||
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
void *player = Resolve("player");
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
|
||||
s_scan.results.clear();
|
||||
s_scan.player = player;
|
||||
s_scan.lootMode = lootMode;
|
||||
BuildQueue(player, maxCount);
|
||||
|
||||
if (s_scan.queue.empty()) {
|
||||
Complete(); // nothing nearby — fire LOOT_SCAN_COMPLETED immediately
|
||||
return true;
|
||||
}
|
||||
TryStartNext();
|
||||
return true;
|
||||
}
|
||||
|
||||
// === Lua bindings ===
|
||||
|
||||
// `C_Loot.ScanNearbyLoot()` — scrape-only walk. Returns `true` if it
|
||||
// started (a subsequent `GetLastScanResults()` holds the contents once
|
||||
// `LOOT_SCAN_COMPLETED` fires), `false` if a walk is already running or
|
||||
// there's no local player.
|
||||
int __fastcall Script_ScanNearbyLoot(void *L) {
|
||||
if (s_scan.state != State::Idle) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
void *player = Resolve("player");
|
||||
if (player == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushBool(L, BeginWalk(/*lootMode=*/false, /*maxCount=*/0));
|
||||
return 1;
|
||||
}
|
||||
|
||||
s_scan.results.clear();
|
||||
s_scan.player = player;
|
||||
BuildQueue(player);
|
||||
|
||||
if (s_scan.queue.empty()) {
|
||||
Complete();
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
TryStartNext();
|
||||
Game::Lua::PushBool(L, true);
|
||||
// `C_Loot.LootAllCorpses([max])` — loots every nearby lootable corpse
|
||||
// (coin + all items) in sequence. Optional `max` caps how many corpses are
|
||||
// visited. Returns `true` if it started, `false` if a walk (scan or loot)
|
||||
// is already in progress or there's no local player. `LOOT_SCAN_COMPLETED`
|
||||
// fires when the walk finishes; `GetLastScanResults()` then reports what
|
||||
// each corpse held (i.e. what was looted).
|
||||
int __fastcall Script_LootAllCorpses(void *L) {
|
||||
const size_t maxCount = Game::Lua::IsNumber(L, 1)
|
||||
? static_cast<size_t>(Game::Lua::ToNumber(L, 1))
|
||||
: 0;
|
||||
Game::Lua::PushBool(L, BeginWalk(/*lootMode=*/true, maxCount));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -462,6 +546,8 @@ int __fastcall Script_GetLastScanResults(void *L) {
|
||||
void Register() {
|
||||
Game::Lua::RegisterTableFunction("C_Loot", "ScanNearbyLoot",
|
||||
&Script_ScanNearbyLoot);
|
||||
Game::Lua::RegisterTableFunction("C_Loot", "LootAllCorpses",
|
||||
&Script_LootAllCorpses);
|
||||
Game::Lua::RegisterTableFunction("C_Loot", "IsScanInProgress",
|
||||
&Script_IsScanInProgress);
|
||||
Game::Lua::RegisterTableFunction("C_Loot", "GetLastScanResults",
|
||||
|
||||
+42
-44
@@ -43,6 +43,7 @@
|
||||
#include "aura/Source.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "event/Custom.h"
|
||||
#include "net/PacketDispatch.h"
|
||||
#include "net/PacketReader.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "tick/WorldTick.h"
|
||||
@@ -111,7 +112,7 @@ const char *ClassifyControlLoss(const uint8_t *rec) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ---- School-interrupt lockout state (fed by the SMSG_SPELL_COOLDOWN hook) --
|
||||
// ---- School-interrupt lockout state (fed by SMSG_SPELL_COOLDOWN) -----------
|
||||
|
||||
// One active lockout per school index (0..6); active while now < endMs.
|
||||
// Self-expiring, per-process — a stale entry from before a relog is already
|
||||
@@ -122,54 +123,51 @@ struct SchoolLock {
|
||||
};
|
||||
SchoolLock g_schoolLock[Offsets::SPELL_SCHOOL_COUNT] = {};
|
||||
|
||||
using CooldownHandler_t = int(__stdcall *)(uint32_t *opCode,
|
||||
Net::CDataStore *packet);
|
||||
CooldownHandler_t g_origCooldown = nullptr;
|
||||
|
||||
int __stdcall Cooldown_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);
|
||||
if (guid != 0 && guid == Unit::Identity::PlayerGuid()) {
|
||||
int count = 0;
|
||||
int firstDur = 0;
|
||||
bool uniform = true;
|
||||
int schoolIdx = -1;
|
||||
while (packet->m_read + 8 <= packet->m_size && count < 256) {
|
||||
const uint32_t spellID = Net::Read<uint32_t>(packet);
|
||||
const int dur = static_cast<int>(Net::Read<uint32_t>(packet));
|
||||
if (count == 0)
|
||||
firstDur = dur;
|
||||
else if (dur != firstDur)
|
||||
uniform = false;
|
||||
if (schoolIdx < 0) {
|
||||
const uint8_t *rec =
|
||||
Spell::Lookup::RecordForID(static_cast<int>(spellID));
|
||||
if (rec != nullptr) {
|
||||
const int s = SpellSchoolIndex(rec);
|
||||
if (s >= 0 && s < Offsets::SPELL_SCHOOL_COUNT)
|
||||
schoolIdx = s;
|
||||
}
|
||||
}
|
||||
++count;
|
||||
}
|
||||
// A uniform-duration batch of >=2 same-school spells is a school
|
||||
// lockout (ProhibitSpellSchool); single/varied packets are normal
|
||||
// cooldowns and ignored.
|
||||
if (count >= 2 && uniform && firstDur > 0 && schoolIdx >= 0) {
|
||||
const int now = NowMs();
|
||||
g_schoolLock[schoolIdx].startMs = now;
|
||||
g_schoolLock[schoolIdx].endMs = now + firstDur;
|
||||
// Subscribe to the SMSG dispatch funnel rather than co-hooking the cooldown
|
||||
// handler (0x006E9460) directly — nampower detours that leaf handler, so
|
||||
// sharing its prologue is the documented MinHook/hadesmem collision risk;
|
||||
// the funnel is a chokepoint nobody else hooks. The dispatcher positions the
|
||||
// cursor at the body (after the u16 opcode) and restores it afterward, so we
|
||||
// just read.
|
||||
void CooldownSub(uint32_t opcode, Net::CDataStore *packet) {
|
||||
if (opcode != Offsets::SMSG_SPELL_COOLDOWN || packet == nullptr)
|
||||
return;
|
||||
const uint64_t guid = Net::Read<uint64_t>(packet);
|
||||
if (guid == 0 || guid != Unit::Identity::PlayerGuid())
|
||||
return;
|
||||
int count = 0;
|
||||
int firstDur = 0;
|
||||
bool uniform = true;
|
||||
int schoolIdx = -1;
|
||||
while (packet->m_read + 8 <= packet->m_size && count < 256) {
|
||||
const uint32_t spellID = Net::Read<uint32_t>(packet);
|
||||
const int dur = static_cast<int>(Net::Read<uint32_t>(packet));
|
||||
if (count == 0)
|
||||
firstDur = dur;
|
||||
else if (dur != firstDur)
|
||||
uniform = false;
|
||||
if (schoolIdx < 0) {
|
||||
const uint8_t *rec =
|
||||
Spell::Lookup::RecordForID(static_cast<int>(spellID));
|
||||
if (rec != nullptr) {
|
||||
const int s = SpellSchoolIndex(rec);
|
||||
if (s >= 0 && s < Offsets::SPELL_SCHOOL_COUNT)
|
||||
schoolIdx = s;
|
||||
}
|
||||
}
|
||||
packet->m_read = saved; // restore before the engine re-parses
|
||||
++count;
|
||||
}
|
||||
// A uniform-duration batch of >=2 same-school spells is a school lockout
|
||||
// (ProhibitSpellSchool); single/varied packets are normal cooldowns and
|
||||
// ignored.
|
||||
if (count >= 2 && uniform && firstDur > 0 && schoolIdx >= 0) {
|
||||
const int now = NowMs();
|
||||
g_schoolLock[schoolIdx].startMs = now;
|
||||
g_schoolLock[schoolIdx].endMs = now + firstDur;
|
||||
}
|
||||
return g_origCooldown(opCode, packet);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _cooldownHook{
|
||||
Offsets::FUN_SPELL_COOLDOWN_HANDLER, reinterpret_cast<void *>(&Cooldown_h),
|
||||
reinterpret_cast<void **>(&g_origCooldown)};
|
||||
const Net::PacketDispatch::AutoSubscribe _cooldownSub{&CooldownSub};
|
||||
|
||||
// ---- Aggregated active-effect list ----------------------------------------
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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 "PacketDispatch.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Net::PacketDispatch {
|
||||
|
||||
namespace {
|
||||
|
||||
PacketSubscriber *g_head = nullptr;
|
||||
|
||||
// `FUN_NET_MESSAGE_DISPATCH` — `__thiscall(netClient, param_1, CDataStore*)`,
|
||||
// rendered here as `__fastcall(self /*ecx*/, edx, param_1, packet)`. The
|
||||
// engine reads the leading u16 opcode from the packet, then dispatches to
|
||||
// `netClient->handlers[opcode]`. We peek the same u16, fan out to subscribers
|
||||
// with the cursor at the body, restore the cursor, and let the original run —
|
||||
// so the engine (and any leaf-handler hooks) see an untouched packet.
|
||||
using Dispatch_t = void(__fastcall *)(void *self, void *edx, uint32_t param_1,
|
||||
CDataStore *packet);
|
||||
Dispatch_t g_orig = nullptr;
|
||||
|
||||
void __fastcall Dispatch_h(void *self, void *edx, uint32_t param_1,
|
||||
CDataStore *packet) {
|
||||
if (packet != nullptr && g_head != nullptr)
|
||||
Net::FanOutByOpcode<uint16_t>(g_head, packet); // opcode is a u16 SMSG id
|
||||
g_orig(self, edx, param_1, packet);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AutoSubscribe::AutoSubscribe(Net::PacketCallback cb) {
|
||||
Net::Subscribe(g_head, this, cb);
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _hook{
|
||||
Offsets::FUN_NET_MESSAGE_DISPATCH,
|
||||
reinterpret_cast<void *>(&Dispatch_h),
|
||||
reinterpret_cast<void **>(&g_orig)};
|
||||
|
||||
} // namespace Net::PacketDispatch
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "net/PacketObserver.h"
|
||||
#include "net/PacketReader.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Shared observer for INCOMING packets — the receive-side mirror of
|
||||
// `Net::SendObserver`. The engine's SMSG dispatch funnel
|
||||
// (`FUN_NET_MESSAGE_DISPATCH`) is the single chokepoint every dispatched SMSG
|
||||
// passes through *before* the engine's per-opcode handler runs.
|
||||
//
|
||||
// Why this exists: co-existing DLLs (notably nampower via hadesmem, plus
|
||||
// SuperWoW) MinHook/detour the individual per-opcode SMSG handlers
|
||||
// (SpellGo/SpellStart/SpellDelayed/…). Two inline hookers on the same prologue
|
||||
// is the documented collision risk. nampower does NOT hook this funnel — it
|
||||
// hooks the leaf handlers — so any feature that only needs to *read* an
|
||||
// incoming packet subscribes here instead, and we stop sharing those
|
||||
// prologues. One hook on a function nobody else patches replaces N hooks on
|
||||
// functions everybody patches.
|
||||
//
|
||||
// Declare a file-scope
|
||||
// static const Net::PacketDispatch::AutoSubscribe _sub{&callback};
|
||||
// Each callback receives the u16 `opcode` (already read) and the `CDataStore`
|
||||
// positioned **right after the opcode** (body start), exactly as the engine's
|
||||
// per-opcode handler would see it. The dispatcher resets that cursor before
|
||||
// every subscriber (so they read independently) and restores the original
|
||||
// `m_read` before handing the packet to the engine. Callbacks must only
|
||||
// READ — never mutate the packet — and must filter on `opcode` themselves.
|
||||
|
||||
namespace Net::PacketDispatch {
|
||||
|
||||
// Chains onto the incoming-packet subscriber list at static-init time. The
|
||||
// list plumbing + fan-out live in net/PacketObserver.h (shared with
|
||||
// Net::SendObserver).
|
||||
struct AutoSubscribe : Net::PacketSubscriber {
|
||||
explicit AutoSubscribe(Net::PacketCallback cb);
|
||||
};
|
||||
|
||||
} // namespace Net::PacketDispatch
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "net/PacketReader.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Shared plumbing for the two packet-observer funnels — the outgoing
|
||||
// `Net::SendObserver` (CMSG, u32 opcode) and the incoming `Net::PacketDispatch`
|
||||
// (SMSG, u16 opcode). Both co-hook a single engine chokepoint and fan each
|
||||
// packet out to opcode subscribers; the only real differences are the opcode
|
||||
// width and the hooked function's ABI. Everything else — the intrusive
|
||||
// subscriber list, the subscribe helper, and the read-opcode / reset-cursor /
|
||||
// fan-out / restore-cursor loop — lives here so a fix to the fan-out logic
|
||||
// isn't made twice.
|
||||
//
|
||||
// Each observer keeps its own file-scope `PacketSubscriber *g_head` and defines
|
||||
// a thin `AutoSubscribe` in its own namespace (so call sites stay
|
||||
// `Net::PacketDispatch::AutoSubscribe{&cb}`); the shared pieces below do the
|
||||
// work.
|
||||
|
||||
namespace Net {
|
||||
|
||||
using PacketCallback = void (*)(uint32_t opcode, CDataStore *packet);
|
||||
|
||||
// Intrusive node for an observer's subscriber list. Each observer's
|
||||
// `AutoSubscribe` derives from this and chains itself on at static-init time.
|
||||
struct PacketSubscriber {
|
||||
PacketCallback cb;
|
||||
PacketSubscriber *next;
|
||||
};
|
||||
|
||||
// Chains `node` (already carrying its `cb`) onto `head`.
|
||||
inline void Subscribe(PacketSubscriber *&head, PacketSubscriber *node,
|
||||
PacketCallback cb) {
|
||||
node->cb = cb;
|
||||
node->next = head;
|
||||
head = node;
|
||||
}
|
||||
|
||||
// Reads the leading opcode (width `OpcodeT`) at the packet's current cursor,
|
||||
// then invokes every subscriber with the cursor reset to the body before each
|
||||
// (so they read independently) and the original `m_read` restored afterward —
|
||||
// so the engine (and any leaf-handler hooks) see an untouched packet.
|
||||
template <typename OpcodeT>
|
||||
inline void FanOutByOpcode(PacketSubscriber *head, CDataStore *packet) {
|
||||
const uint32_t saved = packet->m_read;
|
||||
const uint32_t opcode = static_cast<uint32_t>(Read<OpcodeT>(packet));
|
||||
const uint32_t afterOpcode = packet->m_read;
|
||||
for (auto *node = head; node != nullptr; node = node->next) {
|
||||
packet->m_read = afterOpcode; // each subscriber reads independently
|
||||
node->cb(opcode, packet);
|
||||
}
|
||||
packet->m_read = saved; // hand the engine an untouched cursor
|
||||
}
|
||||
|
||||
} // namespace Net
|
||||
@@ -13,7 +13,8 @@
|
||||
#include <cstdint>
|
||||
|
||||
// Minimal reader for the engine's `CDataStore` packet buffer, shared by the
|
||||
// packet co-hooks (`Aura::Source`'s SpellGo, `Spell::Cast`'s SpellStart).
|
||||
// packet-observer subscribers (`Aura::Source`'s SpellGo, `Spell::Cast`'s
|
||||
// SpellStart, …) that read incoming SMSGs via `Net::PacketDispatch`.
|
||||
//
|
||||
// `CDataStore` is POLYMORPHIC in the engine (virtual Internal*/Reset/etc.),
|
||||
// so a vtable pointer sits at offset 0 and the data members start at +0x04.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// 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 "SendObserver.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
namespace Net::SendObserver {
|
||||
|
||||
namespace {
|
||||
|
||||
PacketSubscriber *g_head = nullptr;
|
||||
|
||||
// `FUN_NET_SEND` — `__thiscall(conn, CDataStore*)`, rendered here as
|
||||
// `__fastcall(conn /*ecx*/, edx, packet)`. The leading u32 of the buffer is
|
||||
// the opcode.
|
||||
using NetSend_t = void(__fastcall *)(void *conn, void *edx, CDataStore *packet);
|
||||
NetSend_t g_origNetSend = nullptr;
|
||||
|
||||
void __fastcall NetSend_h(void *conn, void *edx, CDataStore *packet) {
|
||||
if (packet != nullptr && g_head != nullptr)
|
||||
Net::FanOutByOpcode<uint32_t>(g_head, packet); // opcode is a u32 CMSG id
|
||||
g_origNetSend(conn, edx, packet);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AutoSubscribe::AutoSubscribe(Net::PacketCallback cb) {
|
||||
Net::Subscribe(g_head, this, cb);
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _hook{
|
||||
Offsets::FUN_NET_SEND,
|
||||
reinterpret_cast<void *>(&NetSend_h),
|
||||
reinterpret_cast<void **>(&g_origNetSend)};
|
||||
|
||||
} // namespace Net::SendObserver
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "net/PacketObserver.h"
|
||||
#include "net/PacketReader.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Shared observer for outgoing packets. The engine's NetClient send funnel
|
||||
// (`FUN_NET_SEND`) is a general chokepoint every dispatched CMSG passes
|
||||
// through — not something a single feature should own. MinHook permits one
|
||||
// hook per target, so any module that wants to watch sent packets subscribes
|
||||
// here instead of hooking the funnel itself.
|
||||
//
|
||||
// Mirrors `Tick::WorldTick`: declare a file-scope
|
||||
// `static const Net::SendObserver::AutoSubscribe _sub{&callback};`. The
|
||||
// constructor chains onto the internal list at static-init time.
|
||||
//
|
||||
// Each subscriber's callback receives the leading `opcode` (u32) already
|
||||
// read, and the `CDataStore` positioned **right after the opcode** so it can
|
||||
// read the message body directly. The observer resets that cursor before
|
||||
// every subscriber (so they read independently) and restores the original
|
||||
// `m_read` before handing the packet to the engine. Callbacks must only
|
||||
// READ — never mutate the packet.
|
||||
|
||||
namespace Net::SendObserver {
|
||||
|
||||
// Chains onto the outgoing-packet subscriber list at static-init time. The
|
||||
// list plumbing + fan-out live in net/PacketObserver.h (shared with
|
||||
// Net::PacketDispatch).
|
||||
struct AutoSubscribe : Net::PacketSubscriber {
|
||||
explicit AutoSubscribe(Net::PacketCallback cb);
|
||||
};
|
||||
|
||||
} // namespace Net::SendObserver
|
||||
@@ -25,8 +25,10 @@
|
||||
// mirroring the engine's registration shape exactly (bank 4, size 8). The
|
||||
// dispatcher invokes us only when a slot's item GUID actually changed
|
||||
// (per-node mirror memcmp), so there's no polling and no diff bookkeeping
|
||||
// on our side, and the nodes share the engine observers' lifetime (they
|
||||
// die with the player object; the setup re-runs each enter-world).
|
||||
// on our side. The setup re-runs each enter-world; on logout/login the
|
||||
// player object is recreated so old nodes die with it, but /reload REUSES
|
||||
// the object, so we unregister-then-register per slot to avoid stacking a
|
||||
// duplicate observer each reload (see ObserverSetup_h).
|
||||
//
|
||||
// `equipmentSlot` is the 1-based Lua inventory slot (1 = head … 19 =
|
||||
// tabard), matching modern. `hasCurrent` is 1 when the slot now holds an
|
||||
@@ -126,6 +128,10 @@ int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
|
||||
using RegisterObserver_t = void(__fastcall *)(
|
||||
int bank, uint32_t fieldOffset, uint32_t guidLo, uint32_t guidHi, int size,
|
||||
const void *callback, void *userArg1, void *userArg2);
|
||||
using UnregisterObserver_t = void(__fastcall *)(int bank, uint32_t fieldOffset,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
const void *callback,
|
||||
void *userArg1);
|
||||
using ObserverSetup_t = void(__fastcall *)();
|
||||
ObserverSetup_t g_origSetup = nullptr;
|
||||
|
||||
@@ -147,12 +153,26 @@ void __fastcall ObserverSetup_h() {
|
||||
const uint64_t guid = Unit::Identity::PlayerGuid();
|
||||
if (guid == 0)
|
||||
return;
|
||||
const uint32_t lo = static_cast<uint32_t>(guid);
|
||||
const uint32_t hi = static_cast<uint32_t>(guid >> 32);
|
||||
auto reg = reinterpret_cast<RegisterObserver_t>(
|
||||
Offsets::FUN_DESC_OBSERVER_REGISTER);
|
||||
auto unreg = reinterpret_cast<UnregisterObserver_t>(
|
||||
Offsets::FUN_DESC_OBSERVER_UNREGISTER);
|
||||
for (int slot = 0; slot < Offsets::DESC_PLAYER_EQUIP_SLOTS; ++slot) {
|
||||
reg(Offsets::DESC_OBSERVER_BANK_PLAYER,
|
||||
Offsets::OFF_DESC_PLAYER_EQUIP_FIRST + slot * 8,
|
||||
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
|
||||
const uint32_t field = Offsets::OFF_DESC_PLAYER_EQUIP_FIRST + slot * 8;
|
||||
// Drop any observer we already registered for this field before adding
|
||||
// a fresh one. The setup re-runs on every enter-world, including
|
||||
// /reload — but /reload does NOT destroy the CGPlayer object, so our
|
||||
// previous nodes are still attached and the registrar always APPENDS
|
||||
// (no dedup by callback). Without this, each /reload stacks another
|
||||
// observer per slot → PLAYER_EQUIPMENT_CHANGED fires N times after N
|
||||
// reloads. On logout/login the object is recreated and the old nodes
|
||||
// died with it, so the unregister no-ops there. Same objects-survive-
|
||||
// /reload hazard `Unit::TokenObserver` guards against for nameplates.
|
||||
unreg(Offsets::DESC_OBSERVER_BANK_PLAYER, field, lo, hi,
|
||||
reinterpret_cast<const void *>(&EquipSlotChanged_cb), nullptr);
|
||||
reg(Offsets::DESC_OBSERVER_BANK_PLAYER, field, lo, hi,
|
||||
/*size*/ 8, reinterpret_cast<const void *>(&EquipSlotChanged_cb),
|
||||
nullptr, nullptr);
|
||||
}
|
||||
|
||||
@@ -10,19 +10,27 @@
|
||||
|
||||
// `HEARTHSTONE_BOUND` event — polyfills modern WoW's event of the
|
||||
// same name. Fires every time the player binds their hearthstone at
|
||||
// an innkeeper, even when they re-bind at the SAME inn — matches
|
||||
// an innkeeper, even when they re-bind at the SAME inn — matching
|
||||
// modern semantics where the bind ACTION fires the event regardless
|
||||
// of whether the location string changes.
|
||||
// of whether the location changes.
|
||||
//
|
||||
// Hooks the BINDPOINTUPDATE packet handler at `FUN_005ED3C0` and
|
||||
// gates on `VAR_BIND_POINT_VALID` to suppress only the initial
|
||||
// post-login sync. The engine zeroes that flag during per-character
|
||||
// entry init (`FUN_005E2510`); the handler sets it to 1 after
|
||||
// parsing. If the flag is 0 before the original runs, this packet
|
||||
// is the initial sync — don't fire. Any subsequent invocation is
|
||||
// a real innkeeper rebind. Character switch is handled automatically
|
||||
// because the entry init re-zeroes the flag, so each character's
|
||||
// first update is also treated as sync.
|
||||
// Hooks the BINDPOINTUPDATE packet handler at `FUN_005ED3C0`. Two
|
||||
// non-bind packets must be suppressed:
|
||||
//
|
||||
// 1. Initial post-login / character-switch sync — gated on
|
||||
// `VAR_BIND_POINT_VALID`. The engine zeroes that flag during
|
||||
// per-character entry init (`FUN_005E2510`); the handler sets it
|
||||
// to 1 after parsing. Flag 0 before the original runs ⇒ initial
|
||||
// sync, don't fire.
|
||||
// 2. Map/zone-transition resync — Turtle's server re-sends this
|
||||
// packet on every map change with the (unchanged) bind location.
|
||||
// The valid flag is already 1 by then, so (1) doesn't catch it.
|
||||
// But those packets arrive behind a LOADING SCREEN, and the
|
||||
// player can't bind mid-load — so gate on `VAR_IN_WORLD` (the
|
||||
// engine's "world is live" flag, 0 during any loading screen).
|
||||
// A genuine rebind always arrives with the world live (flag 1),
|
||||
// so this suppresses the resync while still firing for a rebind
|
||||
// at the same innkeeper.
|
||||
//
|
||||
// Event has no payload — addons call `GetBindLocation()` to read
|
||||
// the new location, matching modern semantics.
|
||||
@@ -51,7 +59,14 @@ void __fastcall BindPointUpdateHandler_h(void *packetBuffer) {
|
||||
BindPointUpdateHandler_o(packetBuffer);
|
||||
|
||||
if (wasValid == 0)
|
||||
return; // initial post-login sync — not a user rebind
|
||||
return; // initial post-login / char-switch sync — not a user rebind
|
||||
|
||||
// Resync packets ride the enter-world burst behind a loading screen;
|
||||
// a real innkeeper bind only happens with the world live. The player
|
||||
// can't bind mid-load, so a not-in-world packet is always a resync.
|
||||
if (*reinterpret_cast<const volatile uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
|
||||
return;
|
||||
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
Event::Custom::Fire(slot, "");
|
||||
|
||||
+47
-31
@@ -45,6 +45,7 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "spell/AtCursor.h"
|
||||
#include "spell/AtUnit.h"
|
||||
#include "spell/MacroPrimarySpell.h"
|
||||
#include "unit/Identity.h"
|
||||
#include "unit/Position.h"
|
||||
@@ -55,6 +56,33 @@ namespace Spell::AtUnit {
|
||||
|
||||
namespace {
|
||||
|
||||
// The shared cast-at-unit core, used by both the Lua entry and the C++ API.
|
||||
// Resolves the token once → object, reads position + GUID, dispatches the cast
|
||||
// WITH that GUID, then handles the ground-spell placement branch. `byNumber`
|
||||
// selects spellID vs spellName. Fail-fast so a bad/absent unit doesn't leave
|
||||
// the engine mid-cast.
|
||||
bool CastCore(const char *token, bool byNumber, int spellID, const char *spellName) {
|
||||
void *unitObj = Unit::Position::ResolveToken(token);
|
||||
float pos[3];
|
||||
if (unitObj == nullptr || !Unit::Position::Read(unitObj, pos))
|
||||
return false;
|
||||
const uint64_t targetGuid = Unit::Identity::GuidForObject(unitObj);
|
||||
|
||||
const bool dispatched =
|
||||
byNumber ? Spell::AtCursor::DispatchSpellCast(spellID, targetGuid)
|
||||
: Spell::AtCursor::DispatchSpellCastByName(spellName, targetGuid);
|
||||
if (!dispatched)
|
||||
return false;
|
||||
|
||||
// Unit-target / normal spells fired straight at the GUID — no placement.
|
||||
// Ground spells are now sitting in placement mode; commit at the unit's
|
||||
// feet (CommitAtCoords cancels + fails for a non-ground placement, i.e.
|
||||
// an unaccepted unit target).
|
||||
if (!Spell::AtCursor::IsPlacementActive())
|
||||
return true;
|
||||
return Spell::AtCursor::CommitAtCoords(pos);
|
||||
}
|
||||
|
||||
int __fastcall Script_C_Spell_CastAtUnit(void *L) {
|
||||
// arg1: numeric spellID (exact rank) or spell name ("(Rank N)"-aware).
|
||||
// arg2: unit token.
|
||||
@@ -65,38 +93,12 @@ int __fastcall Script_C_Spell_CastAtUnit(void *L) {
|
||||
return 1;
|
||||
}
|
||||
const char *token = Game::Lua::ToString(L, 2);
|
||||
|
||||
// Resolve the unit once → object, then read position + GUID from it.
|
||||
// Fail fast so a bad/absent unit doesn't leave the engine mid-cast.
|
||||
void *unitObj = Unit::Position::ResolveToken(token);
|
||||
float pos[3];
|
||||
if (unitObj == nullptr || !Unit::Position::Read(unitObj, pos)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
const uint64_t targetGuid = Unit::Identity::GuidForObject(unitObj);
|
||||
|
||||
const bool dispatched =
|
||||
const bool ok =
|
||||
byNumber
|
||||
? Spell::AtCursor::DispatchSpellCast(
|
||||
static_cast<int>(Game::Lua::ToNumber(L, 1)), targetGuid)
|
||||
: Spell::AtCursor::DispatchSpellCastByName(Game::Lua::ToString(L, 1),
|
||||
targetGuid);
|
||||
if (!dispatched) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Unit-target / normal spells fired straight at the GUID — no placement.
|
||||
// Ground spells are now sitting in placement mode; commit at the unit's
|
||||
// feet (CommitAtCoords cancels + fails for a non-ground placement, i.e.
|
||||
// an unaccepted unit target).
|
||||
if (!Spell::AtCursor::IsPlacementActive()) {
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 1;
|
||||
}
|
||||
const bool placed = Spell::AtCursor::CommitAtCoords(pos);
|
||||
Game::Lua::PushBool(L, placed);
|
||||
? CastCore(token, true, static_cast<int>(Game::Lua::ToNumber(L, 1)),
|
||||
nullptr)
|
||||
: CastCore(token, false, 0, Game::Lua::ToString(L, 1));
|
||||
Game::Lua::PushBool(L, ok);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -131,4 +133,18 @@ const Spell::MacroPrimarySpell::PatternAutoRegister _patreg{
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---- C++ API (see AtUnit.h) — same core as the Lua entry, no round-trip -----
|
||||
|
||||
bool CastByName(const char *spellName, const char *unitToken) {
|
||||
if (spellName == nullptr || unitToken == nullptr)
|
||||
return false;
|
||||
return CastCore(unitToken, false, 0, spellName);
|
||||
}
|
||||
|
||||
bool CastByID(int spellID, const char *unitToken) {
|
||||
if (unitToken == nullptr)
|
||||
return false;
|
||||
return CastCore(unitToken, true, spellID, nullptr);
|
||||
}
|
||||
|
||||
} // namespace Spell::AtUnit
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// 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
|
||||
|
||||
// C++ entry to the cast-at-unit primitive behind `C_Spell.CastAtUnit`, so
|
||||
// other C++ modules (the Frame attribute click dispatcher's `spell` verb) can
|
||||
// cast a spell at a unit without a Lua round-trip through the global table.
|
||||
//
|
||||
// Both feed the unit's GUID straight to the engine's cast dispatcher — a
|
||||
// unit-target/normal spell fires directly on the unit (no current-target
|
||||
// juggling), a ground-target spell lands at the unit's feet. Returns true when
|
||||
// the spell was cast. Safe to call from inside a Lua C function: a genuinely
|
||||
// unknown token raises the engine's standard "Unknown unit" error in the
|
||||
// resolver, caught by the enclosing protected call.
|
||||
|
||||
namespace Spell::AtUnit {
|
||||
|
||||
// Cast by name ("(Rank N)"-aware — `"Blizzard"` is the highest known rank).
|
||||
bool CastByName(const char *spellName, const char *unitToken);
|
||||
|
||||
// Cast the exact numeric spellID (a specific rank).
|
||||
bool CastByID(int spellID, const char *unitToken);
|
||||
|
||||
} // namespace Spell::AtUnit
|
||||
+156
-138
@@ -71,8 +71,10 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "net/PacketDispatch.h"
|
||||
#include "net/PacketReader.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "spell/CastEvents.h"
|
||||
#include "tick/WorldTick.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
@@ -232,8 +234,10 @@ const char *SpellIconPath(const uint8_t *rec) {
|
||||
}
|
||||
|
||||
// Pushes UnitCastingInfo's 11-tuple from a tracked cast, or nothing (nil)
|
||||
// if there's no active cast.
|
||||
int PushCastInfo(void *L, const TrackedSpell &c) {
|
||||
// if there's no active cast. `casterGuid` identifies whose cast this is (the
|
||||
// player's or a remote unit's) so `castID` can be pulled from Spell::CastEvents
|
||||
// — the same castGUID the cast's UNIT_SPELLCAST_* events carry.
|
||||
int PushCastInfo(void *L, const TrackedSpell &c, uint64_t casterGuid) {
|
||||
if (c.spellID == 0)
|
||||
return 0;
|
||||
// Self-expire: once the cast window has elapsed, report nothing even if
|
||||
@@ -252,7 +256,12 @@ int PushCastInfo(void *L, const TrackedSpell &c) {
|
||||
PushMs(L, c.startMs); // 4 startTimeMs
|
||||
PushMs(L, c.endMs); // 5 endTimeMs
|
||||
Game::Lua::PushBool(L, IsTradeskill(rec)); // 6 isTradeskill
|
||||
Game::Lua::PushNil(L); // 7 castID
|
||||
char castGuid[48]; // 7 castID
|
||||
if (Spell::CastEvents::CurrentCastGuid(casterGuid, c.spellID, castGuid,
|
||||
sizeof castGuid))
|
||||
Game::Lua::PushString(L, castGuid);
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushBool(L, false); // 8 notInterruptible
|
||||
Game::Lua::PushNumber(L, static_cast<double>(c.spellID)); // 9 castingSpellID
|
||||
Game::Lua::PushNil(L); // 10 castBarID
|
||||
@@ -469,97 +478,81 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
|
||||
const int endMs = instantChannel ? now + RemoteChannelDurationMs(rec)
|
||||
: now + static_cast<int>(castTime);
|
||||
StoreRemoteCast(caster, spellID, now, endMs, instantChannel);
|
||||
// Phase 2 events: START (cast) or CHANNEL_START (channel). Skip pure
|
||||
// instants (non-channel, castTime 0 → no bar); their SUCCEEDED still fires
|
||||
// from the SPELL_GO hook.
|
||||
if (castTime > 0 || instantChannel)
|
||||
Spell::CastEvents::OnRemoteCastStart(caster, spellID, endMs,
|
||||
instantChannel);
|
||||
}
|
||||
|
||||
// Co-hook on the SMSG_SPELL_START handler. Gated on opCode 0x131 so every
|
||||
// other packet this handler processes is a cheap early-out. Packet layout
|
||||
// mirrors nampower's SpellStartHandlerHook.
|
||||
using SpellStartHandler_t = int(__fastcall *)(uint32_t unk, uint32_t opCode,
|
||||
uint32_t unk2,
|
||||
Net::CDataStore *packet);
|
||||
SpellStartHandler_t g_origSpellStart = nullptr;
|
||||
// SMSG_SPELL_START parse. Body (mirrored from nampower's SpellStartHandler):
|
||||
// itemGuid(packed), casterGuid(packed), spellId(u32), castFlags(u16),
|
||||
// castTime(u32). Runs from the Net::PacketDispatch funnel with the cursor
|
||||
// already positioned at the body.
|
||||
void ParseSpellStart(Net::CDataStore *packet) {
|
||||
Net::ReadPackedGuid(packet); // itemGuid (unused)
|
||||
const uint64_t caster = Net::ReadPackedGuid(packet);
|
||||
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
|
||||
Net::Read<uint16_t>(packet); // castFlags (unused)
|
||||
const uint32_t castTime = Net::Read<uint32_t>(packet);
|
||||
HandleSpellStart(caster, spellID, castTime);
|
||||
}
|
||||
|
||||
int __fastcall SpellStartHandler_h(uint32_t unk, uint32_t opCode,
|
||||
uint32_t unk2, Net::CDataStore *packet) {
|
||||
if (opCode == 0x131 && packet != nullptr) {
|
||||
const uint32_t saved = packet->m_read;
|
||||
Net::ReadPackedGuid(packet); // itemGuid (unused)
|
||||
const uint64_t caster = Net::ReadPackedGuid(packet);
|
||||
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
|
||||
Net::Read<uint16_t>(packet); // castFlags (unused)
|
||||
const uint32_t castTime = Net::Read<uint32_t>(packet);
|
||||
packet->m_read = saved; // restore before the engine re-parses
|
||||
HandleSpellStart(caster, spellID, castTime);
|
||||
// SMSG_SPELL_DELAYED — cast pushback. The server only sends it to the
|
||||
// affected caster, so it's effectively always the local player; extend the
|
||||
// tracked cast's end (and report the accumulated delay as delayTimeMs) so an
|
||||
// addon's bar stretches on damage. Body: guid(u64), delay(u32).
|
||||
void ParseSpellDelayed(Net::CDataStore *packet) {
|
||||
const uint64_t guid = Net::Read<uint64_t>(packet);
|
||||
const uint32_t delay = Net::Read<uint32_t>(packet);
|
||||
if (delay != 0 && g_cast.spellID != 0 &&
|
||||
guid == Unit::Identity::PlayerGuid()) {
|
||||
g_cast.endMs += static_cast<int>(delay);
|
||||
g_cast.delayMs += static_cast<int>(delay);
|
||||
}
|
||||
return g_origSpellStart(unk, opCode, unk2, packet);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _spellStartHook{
|
||||
Offsets::FUN_SPELL_START_HANDLER,
|
||||
reinterpret_cast<void *>(&SpellStartHandler_h),
|
||||
reinterpret_cast<void **>(&g_origSpellStart)};
|
||||
|
||||
// Co-hook on the SMSG_SPELL_DELAYED handler — cast pushback. The server
|
||||
// only sends it to the affected caster, so it's effectively always the
|
||||
// local player; extend the tracked cast's end time (and report the
|
||||
// accumulated delay as delayTimeMs) so an addon's bar stretches on damage.
|
||||
using SpellDelayed_t = int(__stdcall *)(uint32_t *opCode,
|
||||
Net::CDataStore *packet);
|
||||
SpellDelayed_t g_origSpellDelayed = nullptr;
|
||||
|
||||
int __stdcall SpellDelayed_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 uint32_t delay = Net::Read<uint32_t>(packet);
|
||||
packet->m_read = saved;
|
||||
if (delay != 0 && g_cast.spellID != 0 &&
|
||||
guid == Unit::Identity::PlayerGuid()) {
|
||||
g_cast.endMs += static_cast<int>(delay);
|
||||
g_cast.delayMs += static_cast<int>(delay);
|
||||
}
|
||||
}
|
||||
return g_origSpellDelayed(opCode, packet);
|
||||
}
|
||||
|
||||
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:
|
||||
// MSG_CHANNEL_START — the server tells the caster a channel has begun, with
|
||||
// the server-authoritative (modifier-applied) duration. Sent only to the
|
||||
// caster, so 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 starting.
|
||||
// For instant channels it re-stamps what the SpellStart path just wrote (same
|
||||
// moment, server duration) — harmless. Running from the dispatch funnel keeps
|
||||
// the stamp ahead of the engine's leaf handler (which fires
|
||||
// SPELLCAST_CHANNEL_START), so the data is fresh when addons poll. Body:
|
||||
// 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();
|
||||
StampChannel(spellID, now, now + static_cast<int>(duration));
|
||||
}
|
||||
void ParseChannelStart(Net::CDataStore *packet) {
|
||||
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
|
||||
const uint32_t duration = Net::Read<uint32_t>(packet);
|
||||
if (spellID != 0 && duration > 0) {
|
||||
const int now = NowMs();
|
||||
StampChannel(spellID, now, now + static_cast<int>(duration));
|
||||
}
|
||||
return g_origChannelStart(opCode, packet);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _channelStartHook{
|
||||
Offsets::FUN_SPELL_CHANNEL_START,
|
||||
reinterpret_cast<void *>(&ChannelStart_h),
|
||||
reinterpret_cast<void **>(&g_origChannelStart)};
|
||||
// MSG_CHANNEL_UPDATE — sent to the channeling PLAYER on damage pushback
|
||||
// (vanilla channels SHORTEN when hit) and once more at the channel's end
|
||||
// (remaining == 0). Body is a single u32 = the new remaining time (ms); the
|
||||
// spell is the in-progress channel (the packet carries no spellID). The engine
|
||||
// stores the new end nowhere, so re-anchor g_channel.endMs to the server's
|
||||
// remaining time so UnitChannelInfo (and the OnWorldTick endMs self-expiry)
|
||||
// track pushback; on remaining == 0 clear the channel so CHANNEL_STOP fires
|
||||
// promptly (ahead of the ~1 s-lagged +0x228 field).
|
||||
void ParseChannelUpdate(Net::CDataStore *packet) {
|
||||
if (g_channel.spellID == 0)
|
||||
return;
|
||||
const uint32_t remaining = Net::Read<uint32_t>(packet);
|
||||
const int spellID = g_channel.spellID;
|
||||
if (remaining == 0) {
|
||||
g_channel.spellID = 0; // authoritative channel end
|
||||
} else {
|
||||
g_channel.endMs = NowMs() + static_cast<int>(remaining);
|
||||
Spell::CastEvents::OnPlayerChannelUpdate(spellID);
|
||||
}
|
||||
}
|
||||
|
||||
// Shared abort handling for the two sibling failure packets. Servers
|
||||
// derived from (v)mangos broadcast BOTH `SMSG_SPELL_FAILURE` and
|
||||
@@ -582,61 +575,31 @@ void HandleCastAborted(uint64_t guid, int spellID) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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 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;
|
||||
HandleCastAborted(guid, spellID);
|
||||
if (guid == Unit::Identity::PlayerGuid()) {
|
||||
// Cast interrupts are surfaced by PollPlayer (g_castSucceeded). Player
|
||||
// CHANNELS never fire INTERRUPTED (retail only fires CHANNEL_STOP for
|
||||
// them, interrupted or not), so there's nothing to do for a channel.
|
||||
if (g_cast.spellID == spellID)
|
||||
g_cast.spellID = 0;
|
||||
return;
|
||||
}
|
||||
return g_origSpellFailedOther(opCode, packet);
|
||||
// Phase 2: remote unit — the poll fires INTERRUPTED + STOP for it.
|
||||
Spell::CastEvents::OnRemoteAborted(guid, spellID);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _spellFailedOtherHook{
|
||||
Offsets::FUN_SPELL_FAILED_OTHER,
|
||||
reinterpret_cast<void *>(&SpellFailedOther_h),
|
||||
reinterpret_cast<void **>(&g_origSpellFailedOther)};
|
||||
|
||||
// Co-hook on the SMSG_SPELL_FAILURE handler — FAILED_OTHER's sibling, with
|
||||
// a trailing result(u8) after the same casterGuid(u64), spellId(u32) head
|
||||
// (we don't read the result). This is the packet that actually stops the
|
||||
// caster's cast VISUAL in the engine's own handler, so it demonstrably
|
||||
// arrives for interrupts; hooking both siblings makes the eviction
|
||||
// independent of which one the server chose to send.
|
||||
using SpellFailure_t = int(__stdcall *)(uint32_t *opCode,
|
||||
Net::CDataStore *packet);
|
||||
SpellFailure_t g_origSpellFailure = nullptr;
|
||||
|
||||
int __stdcall SpellFailure_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;
|
||||
HandleCastAborted(guid, spellID);
|
||||
}
|
||||
return g_origSpellFailure(opCode, packet);
|
||||
// SMSG_SPELL_FAILURE / SMSG_SPELL_FAILED_OTHER — sibling abort packets. The
|
||||
// server broadcasts one (which varies by core / abort path) to observers when
|
||||
// a started cast aborts (interrupt, death, movement, fizzle); handling both
|
||||
// makes eviction independent of which the server chose. Both share the head
|
||||
// casterGuid(u64), spellId(u32) — plain, not packed (SpellFailure has a
|
||||
// trailing result(u8) we don't read). Drops a remote cast the moment it dies
|
||||
// instead of letting the ghost bar run to its computed end.
|
||||
void ParseCastAborted(Net::CDataStore *packet) {
|
||||
const uint64_t guid = Net::Read<uint64_t>(packet);
|
||||
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
|
||||
HandleCastAborted(guid, spellID);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _spellFailureHook{
|
||||
Offsets::FUN_SPELL_FAILURE, reinterpret_cast<void *>(&SpellFailure_h),
|
||||
reinterpret_cast<void **>(&g_origSpellFailure)};
|
||||
|
||||
// Co-hook on CGUnit_C::ClearCastingSpell — the engine choke point every
|
||||
// "unit stopped casting" path funnels through (failure packets, SPELL_GO,
|
||||
// movement, death, animation events, local cancel — see Offsets.h). The
|
||||
@@ -671,6 +634,29 @@ const Game::HookAutoRegister _clearCastingHook{
|
||||
reinterpret_cast<void *>(&ClearCastingSpell_h),
|
||||
reinterpret_cast<void **>(&g_origClearCastingSpell)};
|
||||
|
||||
// One subscriber on the SMSG dispatch funnel, replacing the six per-opcode
|
||||
// co-hooks these spell packets used to install (SpellStart / Delayed /
|
||||
// ChannelStart / ChannelUpdate / Failure / FailedOther) — every one of which
|
||||
// nampower also detours. The dispatcher hands us the cursor at the body (the
|
||||
// opcode already consumed). CastStartSet and ClearCastingSpell stay direct
|
||||
// hooks: they're engine methods, not SMSG handlers, so the funnel can't reach
|
||||
// them (and ClearCastingSpell is the SuperWoW-shared interrupt choke point).
|
||||
void SpellPacketSub(uint32_t opcode, Net::CDataStore *packet) {
|
||||
if (packet == nullptr)
|
||||
return;
|
||||
switch (opcode) {
|
||||
case Offsets::SMSG_SPELL_START: ParseSpellStart(packet); break;
|
||||
case Offsets::SMSG_SPELL_DELAYED: ParseSpellDelayed(packet); break;
|
||||
case Offsets::SMSG_SPELL_CHANNEL_START: ParseChannelStart(packet); break;
|
||||
case Offsets::SMSG_SPELL_CHANNEL_UPDATE: ParseChannelUpdate(packet); break;
|
||||
case Offsets::SMSG_SPELL_FAILURE:
|
||||
case Offsets::SMSG_SPELL_FAILED_OTHER: ParseCastAborted(packet); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
const Net::PacketDispatch::AutoSubscribe _spellPacketSub{&SpellPacketSub};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Per-frame upkeep for the player. The regular cast is stamped by the
|
||||
@@ -714,13 +700,43 @@ void OnWorldTick() {
|
||||
else if (chan == 0 && g_channelConfirmed)
|
||||
g_channel.spellID = 0;
|
||||
}
|
||||
// Self-expiry backstop for the poll: the +0x228 broadcast clears ~1
|
||||
// tick (~1s) late, so a channel that runs its full duration would
|
||||
// otherwise linger until then — the poll's CHANNEL_STOP (and any
|
||||
// g_channel reader) would trail the real end by ~1s. Clear at the
|
||||
// server-computed endMs too, so the effective stop is the EARLIER of
|
||||
// (computed end, early +0x228 clear — the summon-ritual / interrupt
|
||||
// case). Mirrors PushChannelInfo's endMs guard, which already hides
|
||||
// the channel from UnitChannelInfo at this same instant.
|
||||
//
|
||||
// Wrap-safe compare: the ms tick (rdtsc-backed, survives reboots) can
|
||||
// sit past 2^31, where a stored endMs reads NEGATIVE as a signed int.
|
||||
// A direct `now >= endMs` would then misfire for a channel straddling
|
||||
// the boundary. The signed DELTA cancels the wrap (correct for any
|
||||
// interval < ~24.8 days — a channel is always far shorter), so test
|
||||
// `now - endMs >= 0` instead.
|
||||
if (g_channel.spellID != 0 && g_channel.endMs != 0 &&
|
||||
NowMs() - g_channel.endMs >= 0)
|
||||
g_channel.spellID = 0;
|
||||
}
|
||||
|
||||
// Derive the player's UNIT_SPELLCAST_* events from the cast/channel
|
||||
// state above, now that this tick's clears have been applied. Poll-
|
||||
// based so it needs no changes to the many stamp/clear sites; ~1 frame
|
||||
// latency is imperceptible for a cast bar, and every fire is
|
||||
// listener-gated (near-free when no addon uses these).
|
||||
Spell::CastEvents::PollPlayer(g_cast.spellID, g_cast.startMs, g_cast.delayMs,
|
||||
g_channel.spellID, g_channel.startMs);
|
||||
Spell::CastEvents::PollRemote();
|
||||
Spell::CastEvents::PollReticle();
|
||||
}
|
||||
|
||||
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
|
||||
// `CastingInfo()` — local player's cast, no token lookup.
|
||||
static int __fastcall Script_CastingInfo(void *L) { return PushCastInfo(L, g_cast); }
|
||||
static int __fastcall Script_CastingInfo(void *L) {
|
||||
return PushCastInfo(L, g_cast, Unit::Identity::PlayerGuid());
|
||||
}
|
||||
|
||||
// `UnitCastingInfo(unit)` — local player from self-tracking; other units
|
||||
// from the SMSG_SPELL_START cache (regular casts still within their cast
|
||||
@@ -736,11 +752,13 @@ static int __fastcall Script_UnitCastingInfo(void *L) {
|
||||
if (u == nullptr)
|
||||
return 0;
|
||||
if (u == Resolve("player"))
|
||||
return PushCastInfo(L, g_cast);
|
||||
return PushCastInfo(L, g_cast, Unit::Identity::PlayerGuid());
|
||||
|
||||
const RemoteCast *rc = FindRemoteCast(Unit::Identity::GuidForObject(u));
|
||||
const uint64_t guid = Unit::Identity::GuidForObject(u);
|
||||
const RemoteCast *rc = FindRemoteCast(guid);
|
||||
if (rc != nullptr && !rc->isChannel && NowMs() < rc->endMs)
|
||||
return PushCastInfo(L, TrackedSpell{rc->spellID, rc->startMs, rc->endMs});
|
||||
return PushCastInfo(L, TrackedSpell{rc->spellID, rc->startMs, rc->endMs},
|
||||
guid);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
// 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 "CastEvents.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "event/Custom.h"
|
||||
#include "net/PacketReader.h"
|
||||
#include "net/SendObserver.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
|
||||
namespace Spell::CastEvents {
|
||||
|
||||
namespace {
|
||||
|
||||
// Spell.dbc localized string arrays (9 locale slots, 4 bytes each).
|
||||
constexpr int OFF_NAME = 0x1E0; // Name[9]
|
||||
constexpr int OFF_RANK = 0x204; // Rank[9]
|
||||
|
||||
constexpr const char *kStart = "UNIT_SPELLCAST_START";
|
||||
constexpr const char *kStop = "UNIT_SPELLCAST_STOP";
|
||||
constexpr const char *kDelayed = "UNIT_SPELLCAST_DELAYED";
|
||||
constexpr const char *kChannelStart = "UNIT_SPELLCAST_CHANNEL_START";
|
||||
constexpr const char *kChannelStop = "UNIT_SPELLCAST_CHANNEL_STOP";
|
||||
constexpr const char *kChannelUpdate = "UNIT_SPELLCAST_CHANNEL_UPDATE";
|
||||
constexpr const char *kSucceeded = "UNIT_SPELLCAST_SUCCEEDED";
|
||||
constexpr const char *kInterrupted = "UNIT_SPELLCAST_INTERRUPTED";
|
||||
constexpr const char *kFailed = "UNIT_SPELLCAST_FAILED";
|
||||
constexpr const char *kFailedQuiet = "UNIT_SPELLCAST_FAILED_QUIET";
|
||||
constexpr const char *kSent = "UNIT_SPELLCAST_SENT";
|
||||
constexpr const char *kReticleTarget = "UNIT_SPELLCAST_RETICLE_TARGET";
|
||||
constexpr const char *kReticleClear = "UNIT_SPELLCAST_RETICLE_CLEAR";
|
||||
|
||||
const Event::Custom::AutoReserve _rStart{kStart};
|
||||
const Event::Custom::AutoReserve _rStop{kStop};
|
||||
const Event::Custom::AutoReserve _rDelayed{kDelayed};
|
||||
const Event::Custom::AutoReserve _rChannelStart{kChannelStart};
|
||||
const Event::Custom::AutoReserve _rChannelStop{kChannelStop};
|
||||
const Event::Custom::AutoReserve _rChannelUpdate{kChannelUpdate};
|
||||
const Event::Custom::AutoReserve _rSucceeded{kSucceeded};
|
||||
const Event::Custom::AutoReserve _rInterrupted{kInterrupted};
|
||||
const Event::Custom::AutoReserve _rFailed{kFailed};
|
||||
const Event::Custom::AutoReserve _rFailedQuiet{kFailedQuiet};
|
||||
const Event::Custom::AutoReserve _rSent{kSent};
|
||||
const Event::Custom::AutoReserve _rReticleTarget{kReticleTarget};
|
||||
const Event::Custom::AutoReserve _rReticleClear{kReticleClear};
|
||||
|
||||
// Autoshot spams client-side failures while ramping — nampower filters it
|
||||
// out of its failure event, so do we.
|
||||
constexpr uint32_t kAutoShotSpellId = 75;
|
||||
|
||||
// SpellCastTargets mask bit for a unit target (a packed GUID follows).
|
||||
constexpr uint16_t kTargetFlagUnit = 0x0002;
|
||||
|
||||
const char *LocalizedField(const uint8_t *rec, int fieldOffset) {
|
||||
const int locale = *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
|
||||
return *reinterpret_cast<const char *const *>(rec + fieldOffset + locale * 4);
|
||||
}
|
||||
|
||||
// Cast UID for a REAL (Type-3) cast — player and remote alike. Per the retail
|
||||
// spell-cast-GUID spec (warcraft.wiki.gg/wiki/GUID#Cast) for non-local-only
|
||||
// casts: the low 23 bits are the cast's UNIX-epoch second modulo 2^23, and the
|
||||
// higher bits are a counter incrementing for casts observed within the same
|
||||
// second (Creature-GUID-like). Type-2 casts — local-only FAILED casts, "failed
|
||||
// due to requirements not being met" — use a plain incrementing integer
|
||||
// instead (`g_guidCounter`), matching the spec's local-cast rule.
|
||||
int MakeCastUID() {
|
||||
const uint32_t nowSec = static_cast<uint32_t>(std::time(nullptr));
|
||||
static uint32_t s_lastSec = 0;
|
||||
static uint32_t s_perSec = 0;
|
||||
if (nowSec == s_lastSec)
|
||||
++s_perSec;
|
||||
else {
|
||||
s_lastSec = nowSec;
|
||||
s_perSec = 0;
|
||||
}
|
||||
return static_cast<int>((s_perSec << 23) | (nowSec & 0x7FFFFFu));
|
||||
}
|
||||
|
||||
// Modern-shaped castGUID:
|
||||
// `Cast-<type>-<serverID>-<instanceID>-<zoneUID>-<spellID>-<castUID>`.
|
||||
// Vanilla can't know server/instance/zone, so those three fields are 0. `type`
|
||||
// is 3 for real casts (active abilities, channels — the common case) and 2 for
|
||||
// local-only FAILED casts, per the spec's type table. The load-bearing parts
|
||||
// are the spellID (field 6, which addons `strsplit("-")` out) and the unique
|
||||
// per-cast castUID (field 7). Same (type, spellID, castUID) for every event of
|
||||
// one cast → identical string, so START pairs with STOP.
|
||||
void BuildCastGuid(char *out, size_t n, int type, int spellID, int castUID) {
|
||||
std::snprintf(out, n, "Cast-%d-0-0-0-%d-%010X", type, spellID,
|
||||
static_cast<unsigned>(castUID));
|
||||
}
|
||||
|
||||
// Fire a standard-shape event `(unitTarget, castGUID, spellID, spellName,
|
||||
// rank)`. Gated on a listener so an unwatched event does no DBC lookups or
|
||||
// string formatting. A null name/rank pushes `nil` through the dispatcher's
|
||||
// `lua_pushstring(NULL) → pushnil` tail-jump, which is fine.
|
||||
void Fire(const char *eventName, int spellID, int guidNum, int type = 3) {
|
||||
const int slot = Event::Custom::Lookup(eventName);
|
||||
if (!Event::Custom::HasListeners(slot))
|
||||
return;
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
if (rec == nullptr)
|
||||
return;
|
||||
char castGuid[48];
|
||||
BuildCastGuid(castGuid, sizeof(castGuid), type, spellID, guidNum);
|
||||
Event::Custom::Fire(slot, "%s%s%d%s%s", "player", castGuid, spellID,
|
||||
LocalizedField(rec, OFF_NAME),
|
||||
LocalizedField(rec, OFF_RANK));
|
||||
}
|
||||
|
||||
// SENT is the one event whose shape differs from the rest, matching modern:
|
||||
// `(unitTarget, target, castGUID, spellID, spellName, rank)` — the target is
|
||||
// inserted at arg2. `target` is a unit token (e.g. "target") resolved from
|
||||
// the cast packet, or "" for self / no-target casts.
|
||||
void FireSent(int spellID, int guidNum, const char *target) {
|
||||
const int slot = Event::Custom::Lookup(kSent);
|
||||
if (!Event::Custom::HasListeners(slot))
|
||||
return;
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
if (rec == nullptr)
|
||||
return;
|
||||
char castGuid[48];
|
||||
BuildCastGuid(castGuid, sizeof(castGuid), /*type=*/3, spellID, guidNum);
|
||||
Event::Custom::Fire(slot, "%s%s%s%d%s%s", "player", target ? target : "",
|
||||
castGuid, spellID, LocalizedField(rec, OFF_NAME),
|
||||
LocalizedField(rec, OFF_RANK));
|
||||
}
|
||||
|
||||
// Fire a RETICLE event `(unitTarget, castGUID, spellID, spellName, rank)`.
|
||||
// There's no cast yet, so the castGUID slot is empty — retail pushes `nil`
|
||||
// there, but the engine's event dispatcher can't emit a nil mid-format (a
|
||||
// NULL `%s` corrupts every arg after it — the nil trick only survives as the
|
||||
// LAST arg), so we push `""` instead. `unit` (arg1) and `spellID` (arg3) —
|
||||
// the load-bearing fields — are exact; only arg2 differs (`""` vs `nil`),
|
||||
// which is inconsequential for a reticle (no cast to identify). spellName /
|
||||
// rank are our usual extension tail.
|
||||
void FireReticle(const char *eventName, int spellID) {
|
||||
const int slot = Event::Custom::Lookup(eventName);
|
||||
if (!Event::Custom::HasListeners(slot))
|
||||
return;
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
if (rec == nullptr)
|
||||
return;
|
||||
Event::Custom::Fire(slot, "%s%s%d%s%s", "player", "", spellID,
|
||||
LocalizedField(rec, OFF_NAME),
|
||||
LocalizedField(rec, OFF_RANK));
|
||||
}
|
||||
|
||||
// spellID whose reticle we last reported active (0 = no reticle up).
|
||||
int s_reticleSpell = 0;
|
||||
// Set when the reticle spell is actually cast (a CMSG_CAST_SPELL for it went
|
||||
// out) — so the reticle is clearing by PLACEMENT, and RETICLE_CLEAR is
|
||||
// suppressed (retail fires CLEAR only on a real cancel).
|
||||
bool s_reticlePlaced = false;
|
||||
|
||||
// --- Previous-frame snapshot of the player's cast/channel --------------
|
||||
|
||||
// Incrementing integer for Type-2 (local-only FAILED) cast UIDs only. Real
|
||||
// casts get a time-based UID from `MakeCastUID`.
|
||||
int g_guidCounter = 0;
|
||||
|
||||
int g_castSpell = 0; // 0 = not casting
|
||||
int g_castStart = 0; // startMs of the tracked cast (detects same-spell recast)
|
||||
int g_castGuid = 0; // castGUID counter value for the tracked cast
|
||||
int g_castDelay = 0; // last-seen accumulated pushback (ms)
|
||||
bool g_castSucceeded = false; // SPELL_GO seen for the tracked cast this cast
|
||||
|
||||
int g_chanSpell = 0;
|
||||
int g_chanGuid = 0;
|
||||
|
||||
// castGUID minted at SENT, pending until the matching downstream event
|
||||
// (START / SUCCEEDED / FAILED for the same spell) consumes it — so all of
|
||||
// a cast's events share one guid. 0 = none pending.
|
||||
int g_pendingGuid = 0;
|
||||
int g_pendingSpell = 0;
|
||||
|
||||
// The castGUID UID for a real (Type-3) cast of `spellID`: reuse the SENT-
|
||||
// minted pending one when it matches (and consume it), else mint a fresh
|
||||
// time-based UID. Casts with no observed SENT (some engine-internal paths)
|
||||
// just get a fresh one.
|
||||
int NextCastGuid(int spellID) {
|
||||
if (g_pendingGuid != 0 && g_pendingSpell == spellID) {
|
||||
const int g = g_pendingGuid;
|
||||
g_pendingGuid = 0;
|
||||
g_pendingSpell = 0;
|
||||
return g;
|
||||
}
|
||||
return MakeCastUID();
|
||||
}
|
||||
|
||||
// Last cast that ended (fired STOP) + when, so SpellFailed_h can tell a
|
||||
// mid-cast interruption (a started cast that's now aborting — the poll owns
|
||||
// its INTERRUPTED) from a genuine pre-cast failure. The window covers the
|
||||
// race where the poll's cast-end runs a frame before the engine's
|
||||
// Spell_C_SpellFailed for the same cancel.
|
||||
int g_endedSpell = 0;
|
||||
int g_endedTMs = 0;
|
||||
int g_endedGuid = 0;
|
||||
constexpr int kEndWindowMs = 1000;
|
||||
|
||||
// SpellCastResult codes (nampower's game::SpellCastResult) that mean the
|
||||
// cast is being INTERRUPTED rather than failing to start. Movement is the
|
||||
// common one: while you hold the cast key and keep moving, the client
|
||||
// retries and hits Spell_C_SpellFailed(MOVING) each time — modern surfaces
|
||||
// each as a repeated UNIT_SPELLCAST_INTERRUPTED reusing the cast's guid.
|
||||
constexpr int kSpellFailedInterrupted = 35; // 0x23
|
||||
constexpr int kSpellFailedInterruptedCombat = 36; // 0x24
|
||||
constexpr int kSpellFailedMoving = 46; // 0x2E
|
||||
|
||||
bool IsInterruptResult(int code) {
|
||||
return code == kSpellFailedInterrupted ||
|
||||
code == kSpellFailedInterruptedCombat || code == kSpellFailedMoving;
|
||||
}
|
||||
|
||||
// SpellCastResult codes that modern surfaces as UNIT_SPELLCAST_FAILED_QUIET (a
|
||||
// suppressed, no-error-text failure) rather than FAILED. This is a fixed
|
||||
// whitelist, not a "was an error shown" heuristic: verified against the 3.3.5
|
||||
// client's unit-spellcast event dispatch (FUN_007fecc0 selects the QUIET event
|
||||
// for exactly CHARMED / DONT_REPORT / SPELL_IN_PROGRESS), then mapped to 1.12's
|
||||
// SpellCastResult enum (FUN_006e23e0):
|
||||
// CHARMED (0x14) · DONT_REPORT (0x17) · SPELL_IN_PROGRESS (0x61)
|
||||
// SPELL_IN_PROGRESS is the common one — casting while already casting, or a
|
||||
// nampower spell-queue rejection.
|
||||
constexpr int kSpellFailedCharmed = 20; // 0x14
|
||||
constexpr int kSpellFailedSpellInProgress = 97; // 0x61
|
||||
|
||||
bool IsQuietResult(int code) {
|
||||
return code == kSpellFailedCharmed ||
|
||||
code == Offsets::SPELL_FAILED_DONT_REPORT ||
|
||||
code == kSpellFailedSpellInProgress;
|
||||
}
|
||||
|
||||
// A channel's SUCCEEDED is deferred so it fires AFTER CHANNEL_START, matching
|
||||
// modern's order (SENT → CHANNEL_START → SUCCEEDED → CHANNEL_STOP). SPELL_GO
|
||||
// (OnPlayerSucceeded) lands a poll before the channel is detected, so instead
|
||||
// of firing SUCCEEDED there it stashes the channel spell here; PollPlayer
|
||||
// fires it with the channel's guid immediately after CHANNEL_START. The window
|
||||
// is a loss guard — if no CHANNEL_START ever claims it, the poll fires it
|
||||
// standalone rather than dropping it.
|
||||
int g_pendingChanSuccSpell = 0;
|
||||
int g_pendingChanSuccTMs = 0;
|
||||
constexpr int kChanSuccDeferMs = 500;
|
||||
|
||||
// Spell.dbc AttributesEx channel bits — same test Spell::Cast uses.
|
||||
constexpr int OFF_ATTRIBUTES_EX = 0x1C;
|
||||
constexpr uint32_t SPELL_ATTR_EX_CHANNELED = 0x4 | 0x40; // IS_CHANNELED | SELF
|
||||
|
||||
bool IsChanneledSpell(int spellID) {
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
return rec != nullptr &&
|
||||
(*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES_EX) &
|
||||
SPELL_ATTR_EX_CHANNELED) != 0;
|
||||
}
|
||||
|
||||
int NowMs() {
|
||||
using TickMs_t = uint32_t(__fastcall *)();
|
||||
return static_cast<int>(reinterpret_cast<TickMs_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))());
|
||||
}
|
||||
|
||||
// ---- UNIT_SPELLCAST_FAILED (client-side cast rejection) ----------------
|
||||
// Co-hook `Spell_C_SpellFailed` — the local player's own cast failures
|
||||
// (out of range, no mana, not-ready, LoS, …). Recipe (hook point +
|
||||
// filters) from nampower's Spell_C_SpellFailedHook. We don't hook the
|
||||
// SMSG failure PACKET handlers here (Spell::Cast owns those for its cache
|
||||
// eviction); this is the distinct client-side entry.
|
||||
using SpellFailed_t = void(__fastcall *)(uint32_t spellId, int result,
|
||||
int unk1, int unk2, int failedByServer);
|
||||
SpellFailed_t g_origSpellFailed = nullptr;
|
||||
|
||||
void __fastcall SpellFailed_h(uint32_t spellId, int result, int unk1, int unk2,
|
||||
int failedByServer) {
|
||||
g_origSpellFailed(spellId, result, unk1, unk2, failedByServer);
|
||||
if (spellId == kAutoShotSpellId)
|
||||
return; // autoshot ramp spams client failures — filter (like nampower)
|
||||
const int sid = static_cast<int>(spellId);
|
||||
// A "quiet" failure the engine shows NO error text for (already casting,
|
||||
// charmed, spammy retries, reticle cancels, Unleashed-Potential-style fake
|
||||
// fails, …). Modern surfaces those as UNIT_SPELLCAST_FAILED_QUIET rather
|
||||
// than FAILED — see IsQuietResult for the verified 3-code whitelist. Same
|
||||
// type-2 (local-only) castGUID as FAILED.
|
||||
if (IsQuietResult(result & 0xFF)) {
|
||||
Fire(kFailedQuiet, sid, ++g_guidCounter, /*type=*/2);
|
||||
return;
|
||||
}
|
||||
const int now = NowMs();
|
||||
const bool recentCast =
|
||||
g_endedSpell == sid && now - g_endedTMs < kEndWindowMs;
|
||||
|
||||
// Interrupt-class result (moving / kicked): this is the cast being
|
||||
// INTERRUPTED, not failing to start. Modern re-fires INTERRUPTED for
|
||||
// each such retry while you hold the cast key + keep moving, all reusing
|
||||
// the interrupted cast's castGUID. Match that: fire INTERRUPTED with the
|
||||
// in-progress guid, or the just-ended cast's guid, or a fresh one.
|
||||
if (IsInterruptResult(result & 0xFF)) {
|
||||
int guid = g_castSpell == sid ? g_castGuid
|
||||
: recentCast ? g_endedGuid
|
||||
: NextCastGuid(sid);
|
||||
Fire(kInterrupted, sid, guid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-interrupt failure. If it's for a cast that was started (in
|
||||
// progress, or just ended), that cast's end is already reported by the
|
||||
// poll's INTERRUPTED — don't also fire FAILED. Only a failure with NO
|
||||
// started cast is a genuine pre-cast rejection (out of range, no mana,
|
||||
// on cooldown) → UNIT_SPELLCAST_FAILED. This is the spec's Type 2: a
|
||||
// local-only cast that never reached the server, so it gets a plain
|
||||
// incrementing-integer UID (not the time-based real-cast UID).
|
||||
if (g_castSpell == sid || recentCast)
|
||||
return;
|
||||
Fire(kFailed, sid, ++g_guidCounter, /*type=*/2);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _failedHook{
|
||||
Offsets::FUN_SPELL_C_SPELL_FAILED,
|
||||
reinterpret_cast<void *>(&SpellFailed_h),
|
||||
reinterpret_cast<void **>(&g_origSpellFailed)};
|
||||
|
||||
// ---- UNIT_SPELLCAST_SENT (CMSG_CAST_SPELL leaving the client) ----------
|
||||
// Watch outgoing sends via the shared observer (no funnel hook of our own).
|
||||
// This is the earliest point in a cast's life: mint the castGUID here and
|
||||
// stash it pending so the matching START / SUCCEEDED / FAILED reuse it.
|
||||
void OnSend(uint32_t opcode, Net::CDataStore *packet) {
|
||||
if (opcode != Offsets::OP_CMSG_CAST_SPELL)
|
||||
return;
|
||||
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
|
||||
if (spellID == 0)
|
||||
return;
|
||||
// If this is the spell whose reticle is up, the player just PLACED it —
|
||||
// the reticle will clear by placement, not cancel, so suppress its
|
||||
// RETICLE_CLEAR (retail fires CLEAR only on a real cancel).
|
||||
if (s_reticleSpell != 0 && spellID == s_reticleSpell)
|
||||
s_reticlePlaced = true;
|
||||
// CMSG_CAST_SPELL body after spellId is SpellCastTargets: targetMask
|
||||
// (u16), then a packed unit GUID when the UNIT flag is set (self /
|
||||
// no-target casts carry none). Resolve that GUID to a unit token for
|
||||
// SENT's `target` arg. Layout confirmed against the server's
|
||||
// SpellCastTargets::read (tortoise-wow).
|
||||
char target[64];
|
||||
target[0] = '\0';
|
||||
const uint16_t mask = Net::Read<uint16_t>(packet);
|
||||
if (mask & kTargetFlagUnit) {
|
||||
const uint64_t targetGuid = Net::ReadPackedGuid(packet);
|
||||
if (targetGuid != 0)
|
||||
Unit::Identity::TokenFromGUID(targetGuid, target, sizeof(target));
|
||||
}
|
||||
const int guid = MakeCastUID(); // real cast (Type 3) → time-based UID
|
||||
g_pendingGuid = guid;
|
||||
g_pendingSpell = spellID;
|
||||
FireSent(spellID, guid, target);
|
||||
}
|
||||
|
||||
const Net::SendObserver::AutoSubscribe _sendSub{&OnSend};
|
||||
|
||||
// ---- Remote (non-player) unit cast events ------------------------------
|
||||
|
||||
// Fire `eventName` for EVERY unit token currently mapping to `casterGuid`.
|
||||
// The listener gate + DBC name/rank lookup + castGUID build happen once, then
|
||||
// the payload is fanned out per token (`target` / `focus` / `nameplateN` /
|
||||
// `party` / `raid` / `mouseover`). Same `(unit, castGUID, spellID, spellName,
|
||||
// rank)` shape as the player events, just with a non-"player" unit.
|
||||
void FireRemote(const char *eventName, uint64_t casterGuid, int spellID,
|
||||
int guidNum) {
|
||||
const int slot = Event::Custom::Lookup(eventName);
|
||||
if (!Event::Custom::HasListeners(slot))
|
||||
return;
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
if (rec == nullptr)
|
||||
return;
|
||||
char tokens[16][32];
|
||||
const int n = Unit::Identity::TokensForGUID(casterGuid, tokens, 16);
|
||||
if (n == 0)
|
||||
return;
|
||||
char castGuid[48];
|
||||
BuildCastGuid(castGuid, sizeof(castGuid), /*type=*/3, spellID, guidNum);
|
||||
const char *name = LocalizedField(rec, OFF_NAME);
|
||||
const char *rank = LocalizedField(rec, OFF_RANK);
|
||||
for (int i = 0; i < n; ++i)
|
||||
Event::Custom::Fire(slot, "%s%s%d%s%s", tokens[i], castGuid, spellID,
|
||||
name, rank);
|
||||
}
|
||||
|
||||
// One tracked cast per remote caster (a unit can't cast two at once), fired
|
||||
// from the poll so ordering (START -> SUCCEEDED -> STOP) is centralized.
|
||||
struct RemoteEvt {
|
||||
uint64_t guid;
|
||||
int spellID;
|
||||
int guidNum;
|
||||
int endMs;
|
||||
bool isChannel;
|
||||
bool pendingStart; // START/CHANNEL_START not yet fired
|
||||
bool startFired;
|
||||
bool succeeded; // SPELL_GO seen -> completion, not interrupt
|
||||
bool succeededFired;
|
||||
bool aborted; // failure packet / ClearCastingSpell seen
|
||||
bool active;
|
||||
};
|
||||
constexpr int kRemoteEvtSlots = 64;
|
||||
RemoteEvt g_remoteEvt[kRemoteEvtSlots];
|
||||
|
||||
RemoteEvt *FindRemoteEvt(uint64_t guid) {
|
||||
for (auto &e : g_remoteEvt)
|
||||
if (e.active && e.guid == guid)
|
||||
return &e;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
RemoteEvt *AllocRemoteEvt(uint64_t guid) {
|
||||
if (RemoteEvt *existing = FindRemoteEvt(guid))
|
||||
return existing; // replace the caster's prior cast in place
|
||||
for (auto &e : g_remoteEvt)
|
||||
if (!e.active)
|
||||
return &e;
|
||||
return &g_remoteEvt[0]; // pool exhausted (64 casters) — reuse slot 0
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
|
||||
int channelSpellID, int channelStartMs) {
|
||||
// ---- Regular cast -------------------------------------------------
|
||||
// A "new cast" is a different spell OR the same spell re-stamped at a
|
||||
// new start time (a chained same-spell recast — the engine bails
|
||||
// Spell_C_CastSpell on `spellID == current`, so only the fresh startMs
|
||||
// distinguishes it). Fire STOP for the outgoing cast first, then START.
|
||||
const bool newCast = castSpellID != 0 &&
|
||||
(castSpellID != g_castSpell || castStartMs != g_castStart);
|
||||
if (g_castSpell != 0 && (castSpellID == 0 || newCast)) {
|
||||
// A cast that ended without a SPELL_GO for it was interrupted /
|
||||
// cancelled — fire INTERRUPTED before STOP. By this tick, a natural
|
||||
// completion's SPELL_GO (and thus OnPlayerSucceeded) has already run
|
||||
// a frame earlier, so the flag reliably tells the two apart even
|
||||
// though the engine routes both through the same abort choke point.
|
||||
if (!g_castSucceeded)
|
||||
Fire(kInterrupted, g_castSpell, g_castGuid);
|
||||
Fire(kStop, g_castSpell, g_castGuid);
|
||||
g_endedSpell = g_castSpell; // for SpellFailed_h's interrupt-vs-fail gate
|
||||
g_endedGuid = g_castGuid; // repeated movement-interrupts reuse it
|
||||
g_endedTMs = NowMs();
|
||||
g_castSpell = 0;
|
||||
}
|
||||
if (newCast) {
|
||||
g_castGuid = NextCastGuid(castSpellID);
|
||||
g_castSpell = castSpellID;
|
||||
g_castStart = castStartMs;
|
||||
g_castDelay = castDelayMs;
|
||||
g_castSucceeded = false;
|
||||
Fire(kStart, castSpellID, g_castGuid);
|
||||
} else if (g_castSpell != 0 && castDelayMs > g_castDelay) {
|
||||
// Pushback grew (SMSG_SPELL_DELAYED extended the cast).
|
||||
g_castDelay = castDelayMs;
|
||||
Fire(kDelayed, g_castSpell, g_castGuid);
|
||||
}
|
||||
|
||||
// ---- Channel ------------------------------------------------------
|
||||
// Detect a new channel by spellID ONLY (not startMs): a channel is
|
||||
// stamped twice at start — SMSG_SPELL_START, then MSG_CHANNEL_START
|
||||
// re-stamps with the server duration — which changes startMs but is the
|
||||
// SAME channel. A startMs check would spuriously fire STOP+START on the
|
||||
// re-stamp. A genuine re-channel of the same spell passes through
|
||||
// g_chanSpell==0 (CHANNEL_STOP) first, so spellID alone still catches it.
|
||||
(void)channelStartMs;
|
||||
const bool newChan = channelSpellID != 0 && channelSpellID != g_chanSpell;
|
||||
if (g_chanSpell != 0 && (channelSpellID == 0 || newChan)) {
|
||||
// Channels only ever fire CHANNEL_STOP — never INTERRUPTED — whether
|
||||
// they end naturally or are cut short (retail behavior, verified).
|
||||
Fire(kChannelStop, g_chanSpell, g_chanGuid);
|
||||
g_chanSpell = 0;
|
||||
}
|
||||
if (newChan) {
|
||||
// The initiating cast's SENT guid is still pending — a channel's
|
||||
// SUCCEEDED is deferred (OnPlayerSucceeded), so it hasn't consumed the
|
||||
// guid yet. NextCastGuid reuses it, keeping SENT / CHANNEL_START /
|
||||
// SUCCEEDED / CHANNEL_STOP on one castGUID.
|
||||
g_chanGuid = NextCastGuid(channelSpellID);
|
||||
g_chanSpell = channelSpellID;
|
||||
Fire(kChannelStart, channelSpellID, g_chanGuid);
|
||||
// Modern fires SUCCEEDED right after CHANNEL_START. SPELL_GO deferred
|
||||
// it to here so it lands after START, sharing the channel's guid.
|
||||
if (g_pendingChanSuccSpell == channelSpellID) {
|
||||
Fire(kSucceeded, channelSpellID, g_chanGuid);
|
||||
g_pendingChanSuccSpell = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Loss guard: a deferred channel SUCCEEDED that no CHANNEL_START claimed
|
||||
// within the window (the channel never stamped) fires standalone rather
|
||||
// than being dropped.
|
||||
if (g_pendingChanSuccSpell != 0 &&
|
||||
NowMs() - g_pendingChanSuccTMs >= kChanSuccDeferMs) {
|
||||
Fire(kSucceeded, g_pendingChanSuccSpell,
|
||||
NextCastGuid(g_pendingChanSuccSpell));
|
||||
g_pendingChanSuccSpell = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void OnPlayerSucceeded(int spellID) {
|
||||
if (spellID == 0)
|
||||
return;
|
||||
// Channels fire SUCCEEDED *after* CHANNEL_START (modern order). This
|
||||
// SPELL_GO lands a poll before the channel is detected, so defer: stash
|
||||
// the spell and let PollPlayer fire SUCCEEDED right after it fires
|
||||
// CHANNEL_START, sharing the channel's guid.
|
||||
if (IsChanneledSpell(spellID)) {
|
||||
g_pendingChanSuccSpell = spellID;
|
||||
g_pendingChanSuccTMs = NowMs();
|
||||
return;
|
||||
}
|
||||
// Non-channel: fire now. Pair with the tracked cast guid when it's the
|
||||
// same spell (cast-time — START already minted the guid); otherwise an
|
||||
// instant with no tracked cast, so mint a fresh one (pairs with its SENT).
|
||||
int guid;
|
||||
if (g_castSpell == spellID) {
|
||||
guid = g_castGuid;
|
||||
g_castSucceeded = true; // marks this cast a completion, not an interrupt
|
||||
} else {
|
||||
guid = NextCastGuid(spellID); // instant — pairs with its SENT
|
||||
}
|
||||
Fire(kSucceeded, spellID, guid);
|
||||
}
|
||||
|
||||
void PollReticle() {
|
||||
const bool active =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_SPELL_TARGETING_FLAGS) != 0;
|
||||
if (active) {
|
||||
if (s_reticleSpell == 0) {
|
||||
// Reticle just came up — report the pending spell. Read the spellID
|
||||
// now (it's set by Spell_C_CastSpell before targeting begins); if
|
||||
// it's transiently 0 this frame, retry next tick.
|
||||
const int spellID =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_PENDING_CAST_SPELL);
|
||||
if (spellID != 0) {
|
||||
s_reticleSpell = spellID;
|
||||
s_reticlePlaced = false;
|
||||
FireReticle(kReticleTarget, spellID);
|
||||
}
|
||||
}
|
||||
} else if (s_reticleSpell != 0) {
|
||||
// Reticle cleared. Fire CLEAR only if the spell wasn't cast — i.e. the
|
||||
// player CANCELLED (Esc / right-click). A placement (CMSG_CAST_SPELL
|
||||
// went out, s_reticlePlaced set by OnSend) clears the reticle too but
|
||||
// is not a CLEAR event in retail.
|
||||
if (!s_reticlePlaced)
|
||||
FireReticle(kReticleClear, s_reticleSpell);
|
||||
s_reticleSpell = 0;
|
||||
s_reticlePlaced = false;
|
||||
}
|
||||
}
|
||||
|
||||
void OnPlayerChannelUpdate(int spellID) {
|
||||
// Only the tracked, already-started channel gets an UPDATE, reusing its
|
||||
// guid so CHANNEL_START / UPDATE / STOP share one castGUID. A pushback
|
||||
// arrives seconds into a channel — long after the poll fired
|
||||
// CHANNEL_START — so g_chanSpell / g_chanGuid are set by now; if somehow
|
||||
// not, skip (an UPDATE never precedes its START).
|
||||
if (spellID == 0 || g_chanSpell != spellID)
|
||||
return;
|
||||
Fire(kChannelUpdate, spellID, g_chanGuid);
|
||||
}
|
||||
|
||||
// ---- Remote unit entry points ------------------------------------------
|
||||
|
||||
void OnRemoteCastStart(uint64_t casterGuid, int spellID, int endMs,
|
||||
bool isChannel) {
|
||||
if (casterGuid == 0 || spellID == 0)
|
||||
return;
|
||||
RemoteEvt *e = AllocRemoteEvt(casterGuid);
|
||||
*e = RemoteEvt{};
|
||||
e->guid = casterGuid;
|
||||
e->spellID = spellID;
|
||||
e->guidNum = MakeCastUID();
|
||||
e->endMs = endMs;
|
||||
e->isChannel = isChannel;
|
||||
e->pendingStart = true;
|
||||
e->active = true;
|
||||
}
|
||||
|
||||
void OnRemoteSucceeded(uint64_t casterGuid, int spellID) {
|
||||
if (casterGuid == 0 || spellID == 0)
|
||||
return;
|
||||
RemoteEvt *e = FindRemoteEvt(casterGuid);
|
||||
if (e != nullptr && e->spellID == spellID) {
|
||||
e->succeeded = true; // poll fires SUCCEEDED (then STOP) in order
|
||||
return;
|
||||
}
|
||||
// No tracked cast — an instant (no SMSG_SPELL_START). Nothing precedes it,
|
||||
// so fire SUCCEEDED now with a fresh (server-time-based) remote UID.
|
||||
FireRemote(kSucceeded, casterGuid, spellID, MakeCastUID());
|
||||
}
|
||||
|
||||
bool CurrentCastGuid(uint64_t casterGuid, int spellID, char *out,
|
||||
size_t outSize) {
|
||||
if (casterGuid == 0 || spellID == 0 || out == nullptr)
|
||||
return false;
|
||||
int uid;
|
||||
if (casterGuid == Unit::Identity::PlayerGuid()) {
|
||||
// Player's own cast — the guid PollPlayer minted at START (threaded
|
||||
// from SENT). Channels are excluded (g_chanGuid) — UnitCastingInfo is
|
||||
// cast-only.
|
||||
if (g_castSpell != spellID)
|
||||
return false;
|
||||
uid = g_castGuid;
|
||||
} else {
|
||||
// Remote caster — its live event entry (a channel entry is skipped).
|
||||
const RemoteEvt *e = FindRemoteEvt(casterGuid);
|
||||
if (e == nullptr || e->spellID != spellID || e->isChannel)
|
||||
return false;
|
||||
uid = e->guidNum;
|
||||
}
|
||||
BuildCastGuid(out, outSize, /*type=*/3, spellID, uid);
|
||||
return true;
|
||||
}
|
||||
|
||||
void OnRemoteAborted(uint64_t casterGuid, int spellID) {
|
||||
if (casterGuid == 0 || spellID == 0)
|
||||
return;
|
||||
RemoteEvt *e = FindRemoteEvt(casterGuid);
|
||||
if (e != nullptr && e->spellID == spellID)
|
||||
e->aborted = true; // poll fires INTERRUPTED + STOP next frame
|
||||
}
|
||||
|
||||
void PollRemote() {
|
||||
const int now = NowMs();
|
||||
for (auto &e : g_remoteEvt) {
|
||||
if (!e.active)
|
||||
continue;
|
||||
|
||||
// 1. START / CHANNEL_START.
|
||||
if (e.pendingStart) {
|
||||
FireRemote(e.isChannel ? kChannelStart : kStart, e.guid, e.spellID,
|
||||
e.guidNum);
|
||||
e.pendingStart = false;
|
||||
e.startFired = true;
|
||||
}
|
||||
|
||||
// 2. SUCCEEDED (SPELL_GO seen). A regular cast completes here (STOP
|
||||
// follows immediately); a channel keeps running to its endMs.
|
||||
if (e.succeeded && !e.succeededFired) {
|
||||
FireRemote(kSucceeded, e.guid, e.spellID, e.guidNum);
|
||||
e.succeededFired = true;
|
||||
if (!e.isChannel) {
|
||||
FireRemote(kStop, e.guid, e.spellID, e.guidNum);
|
||||
e.active = false;
|
||||
continue;
|
||||
}
|
||||
// A channel's SPELL_GO (which just set `succeeded`) also drives the
|
||||
// engine's cast→channel `ClearCastingSpell` in the same packet
|
||||
// batch — our abort hook sees that. It's the channel STARTING, not
|
||||
// an interrupt, so drop the same-batch abort; otherwise the channel
|
||||
// is killed at its own start. A genuine interrupt lands on a later
|
||||
// poll, when `succeededFired` is already set, so it's still honored.
|
||||
e.aborted = false;
|
||||
}
|
||||
|
||||
// 3. Abort (kick / LoS / death / movement). A cut-short CAST fires
|
||||
// INTERRUPTED then STOP; a CHANNEL fires only CHANNEL_STOP (retail
|
||||
// never emits INTERRUPTED for channels, ended early or not — so the
|
||||
// caster and observer match).
|
||||
if (e.aborted) {
|
||||
if (e.startFired && !e.isChannel)
|
||||
FireRemote(kInterrupted, e.guid, e.spellID, e.guidNum);
|
||||
FireRemote(e.isChannel ? kChannelStop : kStop, e.guid, e.spellID,
|
||||
e.guidNum);
|
||||
e.active = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Natural end at the computed time — the STOP for a channel, and
|
||||
// the backstop for a cast whose SPELL_GO we never saw.
|
||||
if (e.endMs != 0 && now - e.endMs >= 0) {
|
||||
FireRemote(e.isChannel ? kChannelStop : kStop, e.guid, e.spellID,
|
||||
e.guidNum);
|
||||
e.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace Spell::CastEvents
|
||||
@@ -0,0 +1,136 @@
|
||||
// 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.
|
||||
|
||||
#pragma once
|
||||
|
||||
// Backport of the TBC+ `UNIT_SPELLCAST_*` events to 1.12, for the local
|
||||
// player. The underlying cast/channel detection already exists in
|
||||
// `Spell::Cast` (it hooks every relevant path for `UnitCastingInfo` /
|
||||
// `UnitChannelInfo`); this module turns those state transitions into the
|
||||
// retail-named Lua events so ported cast-bar / rotation addons work.
|
||||
//
|
||||
// MinHook allows one hook per target and `Spell::Cast` already owns the
|
||||
// cast-related hooks, so this module doesn't hook anything — the hook
|
||||
// owners call into it. Cast start/stop/channel/pushback are derived by
|
||||
// polling `Spell::Cast`'s player state once per frame (`PollPlayer`,
|
||||
// called from that module's `OnWorldTick`); succeeded/interrupted come
|
||||
// from the SPELL_GO / abort paths in later slices.
|
||||
//
|
||||
// Event contract (arg1 is always the unit token):
|
||||
// (unitTarget, castGUID, spellID, spellName, rank)
|
||||
// The first three match the modern Classic-Era signature, so addons
|
||||
// written against it read `unit, castGUID, spellID` and work; `spellName`
|
||||
// / `rank` are ClassicAPI extension tail args. `castGUID` is a synthesized
|
||||
// unique-per-cast string ("Cast-<n>") — not retail's GUID format, just
|
||||
// stable across a cast's events so START can be paired with STOP.
|
||||
//
|
||||
// Every fire is gated on `Event::Custom::HasListeners`, so when no addon
|
||||
// registers for these the module costs one pointer-compare per transition
|
||||
// and does no arg synthesis.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace Spell::CastEvents {
|
||||
|
||||
// Derive player cast/channel events from a once-per-frame snapshot of
|
||||
// `Spell::Cast`'s player state. Called from `Spell::Cast::OnWorldTick`
|
||||
// after its own upkeep, passing the live `g_cast` / `g_channel` fields.
|
||||
// Detects, against the previous snapshot:
|
||||
// - regular cast start / stop / same-spell recast → UNIT_SPELLCAST_START / _STOP
|
||||
// - accumulated pushback growth → UNIT_SPELLCAST_DELAYED
|
||||
// - channel start / stop → UNIT_SPELLCAST_CHANNEL_START / _STOP
|
||||
// Channels never fire INTERRUPTED — retail emits only CHANNEL_STOP for a
|
||||
// channel whether it ends naturally or is cut short.
|
||||
void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
|
||||
int channelSpellID, int channelStartMs);
|
||||
|
||||
// Derive the ground-target reticle events from the engine's targeting flags.
|
||||
// Called from `Spell::Cast::OnWorldTick`. On the reticle appearing for a
|
||||
// ground-targeted spell (Blizzard, Flare, …) fires UNIT_SPELLCAST_RETICLE_TARGET;
|
||||
// on it clearing (spell placed or cancelled) fires UNIT_SPELLCAST_RETICLE_CLEAR.
|
||||
// Both are `(unitTarget, nil, spellID, spellName, rank)` — always the player.
|
||||
void PollReticle();
|
||||
|
||||
// Fire `UNIT_SPELLCAST_SUCCEEDED` for the local player. Called from the
|
||||
// SPELL_GO hook (`Aura::Source`) when the caster is the player — that
|
||||
// packet is "the spell went off," so it fires for instants (which never
|
||||
// touch the cast state above) as well as completed cast-time / channel
|
||||
// spells. Reuses the in-flight cast's castGUID when the spellID matches,
|
||||
// else mints a fresh one (instant cast). For channels it defers to
|
||||
// `PollPlayer`, which fires SUCCEEDED right after CHANNEL_START so the
|
||||
// order matches modern (CHANNEL_START → SUCCEEDED).
|
||||
void OnPlayerSucceeded(int spellID);
|
||||
|
||||
// Fire `UNIT_SPELLCAST_CHANNEL_UPDATE` for the local player. Called from the
|
||||
// MSG_CHANNEL_UPDATE hook (`Spell::Cast`) when a pushback re-anchors the
|
||||
// player's active channel end. Reuses the tracked channel's castGUID (set by
|
||||
// CHANNEL_START); no-ops if the channel isn't tracked yet (an UPDATE never
|
||||
// precedes its START). The end time itself is read via
|
||||
// `C_Spell.UnitChannelInfo`, matching modern — this event is only the "re-read
|
||||
// now" trigger.
|
||||
void OnPlayerChannelUpdate(int spellID);
|
||||
|
||||
// Write the castGUID string of `casterGuid`'s current CAST of `spellID` into
|
||||
// `out` (>= 48 bytes) and return true, or return false if that unit isn't
|
||||
// tracked as casting this spell. It's the SAME string the cast's events carry
|
||||
// (`SENT`/`START`/…), so `C_Spell.UnitCastingInfo`'s `castID` return can pull
|
||||
// it from here and stay consistent with the events. `casterGuid` is the local
|
||||
// player's GUID for own casts, or the remote caster's GUID. Channels are
|
||||
// excluded (retail's UnitChannelInfo has no castID).
|
||||
bool CurrentCastGuid(uint64_t casterGuid, int spellID, char *out, size_t outSize);
|
||||
|
||||
// `UNIT_SPELLCAST_SENT` is handled internally — the module subscribes to
|
||||
// the shared `Net::SendObserver` and fires it when CMSG_CAST_SPELL leaves
|
||||
// the client (the earliest point in a cast's life). The castGUID minted
|
||||
// there threads forward: the matching START / SUCCEEDED / FAILED reuse it,
|
||||
// so all of a cast's events share one guid.
|
||||
|
||||
// ---- Phase 2: non-player (remote) units --------------------------------
|
||||
//
|
||||
// Remote casts have a narrower data surface than the player's: vanilla only
|
||||
// broadcasts a caster's cast via SMSG_SPELL_START (+ SPELL_GO at completion)
|
||||
// and its abort via the failure packets / ClearCastingSpell choke point. So
|
||||
// remotes get START / STOP / CHANNEL_START / CHANNEL_STOP / SUCCEEDED /
|
||||
// INTERRUPTED, but NOT SENT / DELAYED / FAILED / CHANNEL_UPDATE (that data
|
||||
// is caster-only or client-local and never reaches an observer — see the
|
||||
// remote-unit limitations in CLAUDE.md).
|
||||
//
|
||||
// `Spell::Cast` owns the remote-cast packet hooks; it calls these at the
|
||||
// transition points, and each event is fanned out to EVERY unit token the
|
||||
// caster GUID currently maps to (target / focus / nameplateN / party / raid /
|
||||
// mouseover), via `Unit::Identity::TokensForGUID`. Each remote cast gets its
|
||||
// own synthesized castGUID, shared across its events.
|
||||
|
||||
// A remote unit began a cast (SMSG_SPELL_START). `endMs` is the computed end
|
||||
// (server castTime, or channel duration); `isChannel` selects
|
||||
// CHANNEL_START/STOP vs START/STOP. Called only for real bars — pure instants
|
||||
// (no cast time, not a channel) are skipped by the caller. Fired from the
|
||||
// poll so START lands at frame time in the right order.
|
||||
void OnRemoteCastStart(uint64_t casterGuid, int spellID, int endMs,
|
||||
bool isChannel);
|
||||
|
||||
// A remote unit's spell went off (SMSG_SPELL_GO). Marks the tracked cast so
|
||||
// the poll fires SUCCEEDED (then STOP) in order; if there's no tracked cast
|
||||
// (an instant, which sends no SPELL_START) fires SUCCEEDED immediately with a
|
||||
// fresh castGUID.
|
||||
void OnRemoteSucceeded(uint64_t casterGuid, int spellID);
|
||||
|
||||
// A remote unit's cast was aborted (failure packet / ClearCastingSpell). The
|
||||
// poll fires INTERRUPTED + STOP (or CHANNEL_STOP) for it next frame.
|
||||
void OnRemoteAborted(uint64_t casterGuid, int spellID);
|
||||
|
||||
// Per-frame driver for remote cast events — fires START/CHANNEL_START, then
|
||||
// SUCCEEDED, then STOP/CHANNEL_STOP/INTERRUPTED in modern order as each
|
||||
// tracked remote cast transitions, and expires casts at their computed end.
|
||||
// Called from `Spell::Cast::OnWorldTick` after `PollPlayer`.
|
||||
void PollRemote();
|
||||
|
||||
} // namespace Spell::CastEvents
|
||||
@@ -0,0 +1,81 @@
|
||||
// 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/>.
|
||||
|
||||
// `GetTimeCached()` — a frame-stable companion to vanilla `GetTime()`.
|
||||
//
|
||||
// 1.12's `GetTime()` is *live*: `Script_GetTime` (`0x00515ea0`) calls the
|
||||
// OS tick source (`FUN_OS_TICKCOUNT_MS` → `GetTickCount`, or QPC in
|
||||
// timing-mode 1) on every invocation and scales by 0.001, so two calls in
|
||||
// the same frame can differ. Retail 4.x changed `GetTime()` to read a
|
||||
// value sampled once per frame in the main loop, so all same-frame calls
|
||||
// return the identical timestamp. Rather than change vanilla `GetTime`'s
|
||||
// long-standing live behavior, we expose the frame-stable value under a
|
||||
// new name and leave `GetTime()` alone.
|
||||
//
|
||||
// `GetTimeCached()` returns seconds on the SAME epoch as `GetTime()` (the
|
||||
// OS millisecond counter × 0.001), so the two are directly comparable —
|
||||
// the only difference is that `GetTimeCached()` holds constant for the
|
||||
// duration of a frame. We refresh the cache from the shared once-per-frame
|
||||
// `Tick::WorldTick` hook (which fires at the tail of each world frame).
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Time::Cached {
|
||||
|
||||
namespace {
|
||||
|
||||
// OS millisecond counter sampled at the last frame boundary. 0 until the
|
||||
// first world tick fires (pre-world). Read as uint32 — `GetTickCount`
|
||||
// wraps at ~24.86 days, matching how the rest of the codebase treats this
|
||||
// counter (see aura/Data.cpp), and how `Script_GetTime` masks it to 32
|
||||
// bits before scaling.
|
||||
uint32_t g_frameMs = 0;
|
||||
|
||||
uint32_t SampleTickMs() {
|
||||
using TickCount_t = uint32_t(__fastcall *)();
|
||||
return reinterpret_cast<TickCount_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
|
||||
}
|
||||
|
||||
// Tail-of-frame refresh. Tail-of-frame-N ≈ start-of-frame-(N+1) minus the
|
||||
// inter-frame gap, which is all a frame-stable timestamp needs: every read
|
||||
// during a frame returns the value stamped at the previous boundary.
|
||||
void OnWorldTick() {
|
||||
g_frameMs = SampleTickMs();
|
||||
}
|
||||
|
||||
// `GetTimeCached()` → number (seconds). Frame-stable analog of
|
||||
// `GetTime()`, same epoch. Falls back to a live sample when no frame has
|
||||
// ticked yet (pre-world / glue), so it never returns 0.
|
||||
int __fastcall Script_GetTimeCached(void *L) {
|
||||
uint32_t ms = g_frameMs;
|
||||
if (ms == 0)
|
||||
ms = SampleTickMs();
|
||||
Game::Lua::PushNumber(L, static_cast<double>(ms) * 0.001);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetTimeCached", &Script_GetTimeCached);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Time::Cached
|
||||
@@ -328,6 +328,51 @@ const char *TokenFromGUID(uint64_t target, char *buf, size_t bufSize) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
int TokensForGUID(uint64_t guid, char (*out)[32], int maxTokens) {
|
||||
if (guid == 0 || out == nullptr || maxTokens <= 0)
|
||||
return 0;
|
||||
|
||||
int n = 0;
|
||||
// Engine-native tokens via the reverse map. It fills a shared static
|
||||
// array and returns its base; snapshot each string into `out` before we
|
||||
// do anything re-entrant. Non-throwing (count 0 pre-world).
|
||||
using TokensFromGuid_t =
|
||||
const char *const *(__fastcall *)(const uint64_t *guid, int *outCount);
|
||||
int nativeCount = 0;
|
||||
const char *const *natives = reinterpret_cast<TokensFromGuid_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_UNIT_TOKENS_FROM_GUID))(&guid,
|
||||
&nativeCount);
|
||||
if (natives != nullptr) {
|
||||
for (int i = 0; i < nativeCount && n < maxTokens; ++i) {
|
||||
const char *tok = natives[i];
|
||||
if (tok == nullptr)
|
||||
continue;
|
||||
// SuperWoW patches this reverse map to ALSO emit the caster's raw
|
||||
// GUID string ("0x…") as a token (it makes GUID strings valid unit
|
||||
// tokens). Skip it — the standard tokens are what retail-compat
|
||||
// addons expect; echoing the GUID we were handed is noise.
|
||||
if (tok[0] == '0' && (tok[1] == 'x' || tok[1] == 'X'))
|
||||
continue;
|
||||
std::snprintf(out[n], 32, "%s", tok);
|
||||
++n;
|
||||
}
|
||||
}
|
||||
|
||||
// Synthetic tokens the engine's map can't produce. `focus` resolves via
|
||||
// our token-resolver hook (returns 0 when unset — never raises).
|
||||
if (n < maxTokens && GuidForToken("focus") == guid)
|
||||
std::snprintf(out[n++], 32, "focus");
|
||||
|
||||
// Visible nameplates — sparse slot array, so scan and skip gaps.
|
||||
const int plateCount = NamePlate::Events::GetSlotCount();
|
||||
for (int i = 1; i <= plateCount && n < maxTokens; ++i) {
|
||||
if (NamePlate::Events::GetGUIDByIndex(i) == guid)
|
||||
std::snprintf(out[n++], 32, "nameplate%d", i);
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
static int __fastcall Script_UnitTokenFromGUID(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitTokenFromGUID(\"unitGUID\")");
|
||||
|
||||
@@ -86,6 +86,18 @@ uint64_t GuidForToken(const char *token);
|
||||
// `buf` should be at least 32 bytes.
|
||||
const char *TokenFromGUID(uint64_t guid, char *buf, size_t bufSize);
|
||||
|
||||
// Enumerate EVERY unit token currently mapping to `guid` (not just the
|
||||
// first, unlike `TokenFromGUID`). Writes each token string into `out[i]`
|
||||
// (each row must hold >= 32 bytes) and returns the count, capped at
|
||||
// `maxTokens`. Engine-native tokens (player/pet/target/party/partypet/raid/
|
||||
// raidpet/npc/mouseover) come from the engine's reverse map
|
||||
// (`FUN_UNIT_TOKENS_FROM_GUID`); ClassicAPI's synthetic `focus` /
|
||||
// `nameplateN` are appended on top. The result is snapshotted into `out`
|
||||
// (safe to fire re-entrant Lua events while iterating). Returns 0 pre-world
|
||||
// or when no token maps to `guid`. Used for the per-token `UNIT_SPELLCAST_*`
|
||||
// remote-unit fan-out.
|
||||
int TokensForGUID(uint64_t guid, char (*out)[32], int maxTokens);
|
||||
|
||||
// The engine's cached player-info record for `guid` — the NameCache data block
|
||||
// (name @ `OFF_PLAYER_INFO_NAME`, realm, race @ `_RACE`, sex, class @ `_CLASS`),
|
||||
// or null if the GUID isn't cached. Object-independent: it resolves players who
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// 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/>.
|
||||
|
||||
// `markN` raid-target-marker unit tokens (`mark1`=star … `mark8`=skull).
|
||||
//
|
||||
// Two halves, mirroring `Unit::Focus`:
|
||||
//
|
||||
// 1. Resolution — `GetGUIDByMark` reads the engine's marker table
|
||||
// (`VAR_RAID_TARGET_GUIDS`); the `markN` branch of the token
|
||||
// resolver in `unit/TokenExtensions.cpp` calls it so every stock
|
||||
// `Script_Unit*` (UnitHealth, UnitExists, UnitName, …) accepts
|
||||
// `"markN"` for free.
|
||||
//
|
||||
// 2. Events — `Unit::TokenObserver` watches each marked unit's
|
||||
// descriptor fields so `UNIT_HEALTH` / `UNIT_MANA` / `UNIT_AURA` /
|
||||
// … fire with `arg1 == "markN"`, exactly like a native token. The
|
||||
// engine only auto-watches its own tokens (target/party/raid/pet/
|
||||
// mouseover), so a marked mob that is none of those fires no unit
|
||||
// events until we observe it.
|
||||
//
|
||||
// Why the WorldTick poll (vs. a packet hook): the marker table is written
|
||||
// by the server (`SMSG_RAID_TARGET_UPDATE`), and — more importantly — a
|
||||
// `TokenObserver` can only attach to a LIVE `CGUnit`. A unit marked while
|
||||
// out of range has no object to observe; when it enters range there is no
|
||||
// per-mark event announcing it, and when it leaves range its observer
|
||||
// nodes die with the object silently. So each tick we diff the 8 slots
|
||||
// and (re)attach/detach observers against the current live objects — the
|
||||
// same range-transition handling `Unit::Focus::OnWorldTick` does, ×8. The
|
||||
// idle case (no markers set) costs 8 compares and zero object lookups.
|
||||
//
|
||||
// A marker on a non-unit GUID (totem corpse / gameobject / loot) simply
|
||||
// never resolves to a `CGUnit`, so it is never observed and fires no
|
||||
// events — correct, and matches `UnitExists("markN")` being false there.
|
||||
|
||||
#include "unit/RaidTarget.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "tick/WorldTick.h"
|
||||
#include "unit/TokenObserver.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Unit::RaidTarget {
|
||||
|
||||
namespace {
|
||||
|
||||
// `"mark1".."mark8"` — the token strings fired as `arg1`. Static so the
|
||||
// pointers stay valid for the `FUN_FIRE_EVENT` "%s" argument.
|
||||
const char *const kMarkTokens[kMarkCount] = {
|
||||
"mark1", "mark2", "mark3", "mark4",
|
||||
"mark5", "mark6", "mark7", "mark8",
|
||||
};
|
||||
|
||||
// GUID we are currently tracking for each marker slot (mirrors the live
|
||||
// marker table), or 0. Doubles as the callback's stale-fire guard.
|
||||
uint64_t g_markGUID[kMarkCount] = {};
|
||||
|
||||
// Whether a live descriptor observer is currently registered on
|
||||
// `g_markGUID[i]`. Cleared when that unit leaves the object table so the
|
||||
// tick re-registers when it returns (nodes die with the object).
|
||||
bool g_markObserved[kMarkCount] = {};
|
||||
|
||||
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
int priority);
|
||||
|
||||
// Resolve a GUID to its live `CGUnit`, or null when it isn't currently in
|
||||
// the client's object table (out of range / despawned / a non-unit
|
||||
// marker). Non-throwing — safe from the tick. Mirrors `Focus::ResolveObject`.
|
||||
const uint8_t *ResolveObject(uint64_t guid) {
|
||||
if (guid == 0)
|
||||
return nullptr;
|
||||
auto resolve = reinterpret_cast<ResolveByGUID_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
|
||||
return static_cast<const uint8_t *>(
|
||||
resolve(Offsets::OBJ_TYPE_UNIT, "RaidTarget", static_cast<uint32_t>(guid),
|
||||
static_cast<uint32_t>(guid >> 32), 0x172));
|
||||
}
|
||||
|
||||
// Descriptor-field observer callback for marker slot `Idx` (0-based).
|
||||
// Fires the changed field's unit event (`fieldOffset >> 2`) with the
|
||||
// `"markN"` token, so `arg1 == "markN"` works for `UNIT_HEALTH`,
|
||||
// `UNIT_MANA`, `UNIT_AURA`, … — exactly like `FocusFieldCb`. Guarded on
|
||||
// the live tracked GUID so an in-flight field change racing an unregister
|
||||
// can't fire a stale token.
|
||||
template <int Idx>
|
||||
int __fastcall MarkFieldCb(uint32_t fieldOffset, uint32_t /*size*/,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
const uint32_t * /*oldValue*/, void * /*userArg*/) {
|
||||
const uint64_t g = g_markGUID[Idx];
|
||||
if (guidLo != static_cast<uint32_t>(g) || guidHi != static_cast<uint32_t>(g >> 32))
|
||||
return 1;
|
||||
using Fire_t = void(__cdecl *)(int eventId, const char *fmt, ...);
|
||||
reinterpret_cast<Fire_t>(static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT))(
|
||||
static_cast<int>(fieldOffset >> 2), "%s", kMarkTokens[Idx]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// One distinct callback per slot — `TokenObserver` keys observer nodes on
|
||||
// the callback pointer, and each fires its own fixed token, so the slots
|
||||
// must not share a function.
|
||||
const TokenObserver::FieldCallback g_markCb[kMarkCount] = {
|
||||
&MarkFieldCb<0>, &MarkFieldCb<1>, &MarkFieldCb<2>, &MarkFieldCb<3>,
|
||||
&MarkFieldCb<4>, &MarkFieldCb<5>, &MarkFieldCb<6>, &MarkFieldCb<7>,
|
||||
};
|
||||
|
||||
const uint64_t *MarkerTable() {
|
||||
return reinterpret_cast<const uint64_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_RAID_TARGET_GUIDS));
|
||||
}
|
||||
|
||||
// Keep each slot's observer pointed at the unit currently wearing that
|
||||
// marker. Per slot: adopt a changed/cleared assignment (dropping the old
|
||||
// observer), then (re)register on the live object or note a despawn. The
|
||||
// heavy path (ResolveObject) runs only for slots that actually hold a
|
||||
// marker, so an unmarked table is 8 cheap compares.
|
||||
void OnWorldTick() {
|
||||
const uint64_t *table = MarkerTable();
|
||||
for (int i = 0; i < kMarkCount; ++i) {
|
||||
const uint64_t want = table[i];
|
||||
if (want != g_markGUID[i]) {
|
||||
// Marker moved to a different GUID (or was cleared): stop
|
||||
// watching the old unit, adopt the new assignment.
|
||||
if (g_markObserved[i]) {
|
||||
TokenObserver::Unregister(g_markGUID[i], g_markCb[i]);
|
||||
g_markObserved[i] = false;
|
||||
}
|
||||
g_markGUID[i] = want;
|
||||
}
|
||||
if (g_markGUID[i] != 0) {
|
||||
const bool live = ResolveObject(g_markGUID[i]) != nullptr;
|
||||
if (live && !g_markObserved[i]) {
|
||||
TokenObserver::Register(g_markGUID[i], g_markCb[i]);
|
||||
g_markObserved[i] = true;
|
||||
} else if (!live && g_markObserved[i]) {
|
||||
// Marked unit left the object table (out of range /
|
||||
// despawn); its observer nodes died with it. Re-register
|
||||
// when it returns.
|
||||
g_markObserved[i] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
|
||||
// Reset tracking on logout→character-select. The DLL isn't reloaded
|
||||
// across a relog, so stale per-slot GUIDs from character A must not leak
|
||||
// into B. Observer nodes already died with the world teardown, and the
|
||||
// tick re-reads the (fresh) marker table anyway, so we only clear our
|
||||
// mirror. Same rationale as `Focus::ClearOnLogout`; glue hook so it fires
|
||||
// on logout but NOT on /reload.
|
||||
void ClearOnLogout() {
|
||||
for (int i = 0; i < kMarkCount; ++i) {
|
||||
g_markGUID[i] = 0;
|
||||
g_markObserved[i] = false;
|
||||
}
|
||||
}
|
||||
const Game::GlueModuleAutoRegister _clearOnLogout{&ClearOnLogout};
|
||||
|
||||
} // namespace
|
||||
|
||||
uint64_t GetGUIDByMark(int mark) {
|
||||
if (mark < 1 || mark > kMarkCount)
|
||||
return 0;
|
||||
return MarkerTable()[mark - 1];
|
||||
}
|
||||
|
||||
} // namespace Unit::RaidTarget
|
||||
@@ -0,0 +1,31 @@
|
||||
// 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 Unit::RaidTarget {
|
||||
|
||||
// Number of raid-target markers (star … skull), i.e. the valid `markN`
|
||||
// range is `1..kMarkCount`.
|
||||
constexpr int kMarkCount = 8;
|
||||
|
||||
// GUID currently wearing raid marker `mark` (`1..kMarkCount`), or `0`
|
||||
// when that marker is unassigned / `mark` is out of range. Reads the
|
||||
// engine's marker table directly (`VAR_RAID_TARGET_GUIDS`). Consumed by
|
||||
// the `markN` branch of the token resolver in `unit/TokenExtensions.cpp`,
|
||||
// mirroring `NamePlate::Events::GetGUIDByIndex` / `Unit::Focus::Get`.
|
||||
uint64_t GetGUIDByMark(int mark);
|
||||
|
||||
} // namespace Unit::RaidTarget
|
||||
@@ -27,6 +27,18 @@
|
||||
// See `unit/Focus.cpp` for the storage + `PLAYER_FOCUS_CHANGED`
|
||||
// event firing.
|
||||
//
|
||||
// - `mark1`..`mark8` — raid-target markers (star … skull), resolved
|
||||
// from the engine's marker table via `Unit::RaidTarget`. That module
|
||||
// also fires per-`markN` unit events (`UNIT_HEALTH`, …) through the
|
||||
// same `TokenObserver` path as `focus`/`nameplateN`.
|
||||
//
|
||||
// - `0x<hex>` — a raw 64-bit GUID literal (SuperWoW's format:
|
||||
// `"0x"` + up to 16 hex digits). Lets every `Unit*` accept a GUID
|
||||
// in place of a token, which is SuperWoW's headline "hard
|
||||
// dependency" feature. Only enabled when SuperWoW is NOT loaded —
|
||||
// when it is, its own resolver hook owns GUID input and we defer to
|
||||
// avoid double-handling. See `GuidTokenEnabled` below.
|
||||
//
|
||||
// Both families compose with the engine's own `target`-suffix walker
|
||||
// (`focustarget`, `nameplate1targettarget`) via the shared
|
||||
// `WalkSuffix` helper, which mirrors the engine's
|
||||
@@ -42,13 +54,40 @@
|
||||
#include "Offsets.h"
|
||||
#include "nameplate/Walk.h"
|
||||
#include "unit/Focus.h"
|
||||
#include "unit/RaidTarget.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace Unit::TokenExtensions {
|
||||
|
||||
namespace {
|
||||
|
||||
// SuperWoW (loaded by VanillaFixes from `dlls.txt` as `SuperWoWhook.dll`)
|
||||
// already makes the token resolver accept `"0x..."` GUID literals, so we
|
||||
// only add it ourselves when SuperWoW is absent — otherwise both would
|
||||
// parse the same input. VanillaFixes `LoadLibrary`s every listed DLL during
|
||||
// its injection pass, long before this hook installs at game boot, so the
|
||||
// module handle is present iff SuperWoW is active. Cached — module presence
|
||||
// is fixed for the process lifetime.
|
||||
bool GuidTokenEnabled() {
|
||||
static const bool enabled =
|
||||
GetModuleHandleA("SuperWoWhook.dll") == nullptr;
|
||||
return enabled;
|
||||
}
|
||||
|
||||
// Hex nibble, or -1 for a non-hex character. Case-insensitive.
|
||||
int HexNibble(char c) {
|
||||
if (c >= '0' && c <= '9')
|
||||
return c - '0';
|
||||
if (c >= 'a' && c <= 'f')
|
||||
return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F')
|
||||
return c - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
|
||||
// `__stdcall`, not `__cdecl` — `FUN_00064A4C0` ends with `ret 0xc`,
|
||||
// callee cleans 3-arg stack frame. Calling it through a cdecl typedef
|
||||
@@ -64,6 +103,8 @@ constexpr const char kNamePlatePrefix[] = "nameplate";
|
||||
constexpr int kNamePlatePrefixLen = static_cast<int>(sizeof(kNamePlatePrefix) - 1);
|
||||
constexpr const char kFocusPrefix[] = "focus";
|
||||
constexpr int kFocusPrefixLen = static_cast<int>(sizeof(kFocusPrefix) - 1);
|
||||
constexpr const char kMarkPrefix[] = "mark";
|
||||
constexpr int kMarkPrefixLen = static_cast<int>(sizeof(kMarkPrefix) - 1);
|
||||
constexpr const char kSuffixTarget[] = "target";
|
||||
constexpr int kSuffixTargetLen = static_cast<int>(sizeof(kSuffixTarget) - 1);
|
||||
|
||||
@@ -110,6 +151,25 @@ uint64_t __fastcall Hook_h(const char *token) {
|
||||
if (token == nullptr || *token == '\0')
|
||||
return Original_o(token);
|
||||
|
||||
// Raw GUID literal `0x<hex>` (SuperWoW input format). The parsed GUID
|
||||
// is returned straight to the engine's own back-half of the resolver
|
||||
// (`FUN_00468460(unit-mask, guid)`), the identical path `"player"`
|
||||
// takes — so an out-of-range / unloaded GUID resolves to nil exactly
|
||||
// like any absent unit, no special handling. Feeds the same suffix
|
||||
// walker as the other families, so `"0x…target"` composes too.
|
||||
if (GuidTokenEnabled() && token[0] == '0' &&
|
||||
(token[1] == 'x' || token[1] == 'X')) {
|
||||
const char *p = token + 2;
|
||||
uint64_t guid = 0;
|
||||
int digits = 0;
|
||||
for (int nibble; (nibble = HexNibble(*p)) >= 0; ++p) {
|
||||
guid = (guid << 4) | static_cast<uint32_t>(nibble);
|
||||
++digits;
|
||||
}
|
||||
if (digits > 0)
|
||||
return WalkSuffix(guid, p);
|
||||
}
|
||||
|
||||
auto sstrcmpi = reinterpret_cast<SStrCmpI_t>(Offsets::FUN_SSTR_CMP_I);
|
||||
|
||||
// `focus` / `focustarget*`. The 5-char prefix is unique among
|
||||
@@ -140,6 +200,22 @@ uint64_t __fastcall Hook_h(const char *token) {
|
||||
}
|
||||
}
|
||||
|
||||
// `markN` / `mark1target*`. Raid-target markers, one digit `1`..`8`
|
||||
// after the prefix (`mark1` = star … `mark8` = skull). Anything else
|
||||
// (`mark`, `mark0`, `mark9`) falls through to the engine. Single
|
||||
// digit only, so `mark1target` leaves `p` at "target" for the walker.
|
||||
if (sstrcmpi(token, kMarkPrefix, kMarkPrefixLen) == 0) {
|
||||
const char *p = token + kMarkPrefixLen;
|
||||
if (*p >= '1' && *p <= '8') {
|
||||
const int mark = *p - '0';
|
||||
++p;
|
||||
const uint64_t guid = Unit::RaidTarget::GetGUIDByMark(mark);
|
||||
if (guid == 0)
|
||||
return 0;
|
||||
return WalkSuffix(guid, p);
|
||||
}
|
||||
}
|
||||
|
||||
return Original_o(token);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user