Compare commits

..
8 Commits
Author SHA1 Message Date
Brues 39dcfc73de event: add GetFramesRegisteredForEvent(event)
Returns the frames currently subscribed to an event as multiple return
values (retail's vararg signature). Reads the engine's own subscriber
chain off the event-table entry (entry+0xC head; node next @+0x04,
frame @+0x08, low-bit/null end sentinel) — the exact list
frame:RegisterEvent appends to, so it stays in sync with real
registrations, including AutoReserve custom events. Each frame is
pushed via the shared UI::FrameObject wrapper so frame methods work.
A counting pass drives lua_checkstack up front against heavily
subscribed events.
2026-07-21 14:41:10 -05:00
Brues 6e5f7c3e43 tradeskilllink: gate detail wheel on overflow
The detail ScrollFrame had mouse wheel enabled unconditionally, so even
when the content fit (bar hidden, 0 range) it still consumed wheel events
— clamping to a no-op but swallowing the scroll instead of letting it pass
through. Enable the wheel from SetDetailBarShown alongside the bar, so it's
on only when there's something to scroll.
2026-07-21 14:25:43 -05:00
Brues 2e05e86362 tradeskilllink: fix detail-pane scroll range + auto-hide
The trade-skill-link viewer's reagent pane couldn't scroll to the last
row for some recipes (issue #10). Root cause, found by comparing against
Blizzard's Blizzard_TradeSkillUI: resizing a ScrollFrame's child does NOT
recompute its scroll range in 1.12 — you must call UpdateScrollChildRect()
(TradeSkillFrame_SetSelection does exactly this). We resized the child but
never refreshed the range, so the bar's travel was capped at a stale max.

- Call detailScroll:UpdateScrollChildRect() after (re)sizing the child in
  RenderDetail and ClearDetail.
- Size the child from actual laid-out geometry (bottom of the last reagent
  button relative to the child top + name-plate overhang) instead of a
  hand-tallied constant that undercounted.
- Drive scrollbar visibility from the computed overflow (SetDetailBarShown)
  rather than the engine's range-changed event: scrollBarHideable only
  auto-hides on a range CHANGE, so content that fits from the start (e.g.
  Turtle's roomy pane) never hid, and GetVerticalScrollRange isn't reliably
  readable the same tick as UpdateScrollChildRect.

Net removes the old detailNeedScroll bool-tracking, per-need mouse-wheel
toggling, and the OnShow re-assert workaround — all of which were
compensating for the missing range recompute.

Fixes #10
2026-07-21 14:21:25 -05:00
Brues bcc631c4ed tradeskilllink: fix ColorMixin clobber from Fonts.xml re-includers
Addons that re-include ..\..\FrameXML\Fonts.xml in their TOC (notably
SpecialTalentUI, LoadOnDemand — pulled in when the talent frame is first
opened) re-run its inline script, which resets NORMAL_FONT_COLOR /
HIGHLIGHT_FONT_COLOR / GRAY_FONT_COLOR to plain {r,g,b} tables and strips
the ColorMixin methods our UI relies on. That crashed the trade-skill-link
viewer's NORMAL_FONT_COLOR:GetRGB() (issue #10).

Factor the standard color-global setup into CAPI_ApplyStandardColorGlobals
(idempotent — only (re)creates a tag that's missing :GetRGB) and re-run it
after SpecialTalentUI loads via EventUtil.ContinueOnAddOnLoaded, next to the
existing pfUI compat in AddOnCompat.lua.

Also simplify the viewer frame anchor to SetPoint("CENTER").
2026-07-21 12:54:39 -05:00
Brues f9fb32186d TradeSkillLink: Play Open/Close Sound 2026-07-21 11:11:41 -05:00
Brues 60adbba7c3 pointless fallback 2026-07-21 11:11:09 -05:00
Brues c35d883af0 targeting: backport TargetDirection*/TargetNearest* + GetPlayerFacing
Adds the post-vanilla TargetScript selection functions 1.12.1 lacks:

  TargetDirectionEnemy(facing[,cone]) / TargetDirectionFriend
  TargetNearest([reverse])
  TargetNearestEnemyPlayer([reverse]) / TargetNearestFriendPlayer
  GetPlayerFacing()

All five targeting functions reuse the engine's own tab-target core: the
visible-unit enumeration, the per-mode validity predicate FUN_00493e40
(reaction/attackability/alive, modes 1=enemy/2=friend), and the selection
commit. The only new logic is a player-GUID filter for the *Player variants
and the cone geometry for the direction variants. Cycle functions keep a
frozen per-filter snapshot advanced within a ~1s window (no ping-pong).

Target-setting routes through FUN_TARGET_BY_GUID (0x00489A40), which
rejects a GUID that no longer resolves to a live unit rather than
committing it — a stale snapshot GUID would otherwise leave "target"
pointing at freed unit data and crash the next handler that reads it.

GetPlayerFacing reads the CGUnit movement-block orientation at +0x9C4
(located by probing the player object across a known 180-degree turn); it's
the facing source the direction-target functions need.

Not backported (no vanilla equivalent): TargetPriorityHighlightStart/End,
IsTargetLoose, TargetToggle.

New offsets: FUN_TARGET_CANDIDATE_VALID (0x00493E40),
FUN_SET_SELECTION_BY_GUID (0x00493540), VAR_CURRENT_SELECTION_GUID_LO/HI,
OFF_UNIT_FACING (0x9C4).
2026-07-21 00:49:05 -05:00
Brues c202c1000f chatbubbles: add C_ChatBubbles.GetAllChatBubbles
Returns the active chat-bubble Frame objects, matching modern WoW's
7.2.5 signature. Vanilla's CGChatBubbleFrames are full CSimpleFrames
the engine creates in C++ without CreateFrame (same as default
nameplates), kept on an intrusive Storm TSList at 0x00B6F038 (next
link +0x318, low-bit sentinel). Mirror the engine's own walk
(FUN_004b1060) and push each as its canonical engine wrapper, so the
returned frames are real and method-capable and their text FontString
(+0x31c) surfaces via :GetRegions().

includeForbidden is accepted and ignored (vanilla has no forbidden
frames).

Factor the ScriptRegister->rawgeti wrapper push out of nameplate/Info
into a shared UI::FrameObject::Push; PushNamePlateFrame now forwards to
it (no behavior change).
2026-07-20 23:00:03 -05:00
13 changed files with 929 additions and 132 deletions
+5 -1
View File
@@ -38,4 +38,8 @@ if C_AddOns.DoesAddOnExist('pfUI') then
end)
end
end)
end
end
if C_AddOns.DoesAddOnExist("SpecialTalentUI") then
EventUtil.ContinueOnAddOnLoaded("SpecialTalentUI", CAPI_ApplyStandardColorGlobals)
end
+7 -4
View File
@@ -108,11 +108,14 @@ for _, v in pairs(ITEM_QUALITY_COLORS) do
v.color = CreateColor(v.r, v.g, v.b, 1)
end
do
function CAPI_ApplyStandardColorGlobals()
local envTbl = _G
for _, dbColor in ipairs(C_UIColor.GetColors()) do
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
envTbl[dbColor.baseTag] = color
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
if not envTbl[dbColor.baseTag] or not envTbl[dbColor.baseTag].GetRGB then
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
envTbl[dbColor.baseTag] = color
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
end
end
end
CAPI_ApplyStandardColorGlobals()
+54 -60
View File
@@ -248,7 +248,7 @@ end
local function CreateFrame_TradeSkillLink()
local f = CreateFrame("Frame", "ClassicAPITradeSkillLinkFrame", UIParent);
f:SetSize(FRAME_W, FRAME_H);
f:SetPoint("CENTER", 0, 0);
f:SetPoint("CENTER");
f:SetMovable(true);
f:EnableMouse(true);
f:SetHitRectInsets(LO.hit[1], LO.hit[2], LO.hit[3], LO.hit[4]);
@@ -259,6 +259,9 @@ local function CreateFrame_TradeSkillLink()
f:Hide();
tinsert(UISpecialFrames, f:GetName())
f:SetScript("OnShow", function() PlaySound("igCharacterInfoOpen"); end);
f:SetScript("OnHide", function() PlaySound("igCharacterInfoClose"); end);
local portrait = f:CreateTexture(nil, "BACKGROUND");
portrait:SetSize(60, 60);
portrait:SetPoint("TOPLEFT", 7, -6);
@@ -296,7 +299,7 @@ local function CreateFrame_TradeSkillLink()
local exit = CreateFrame("Button", nil, f, "UIPanelButtonTemplate");
exit:SetSize(80, 22);
exit:SetPoint(LO.exit[1], f, LO.exit[2], LO.exit[3], LO.exit[4]);
exit:SetText(EXIT or "Exit");
exit:SetText(EXIT);
exit:SetScript("OnClick", function() f:Hide(); end);
local bar = CreateFrame("StatusBar", nil, f);
@@ -393,29 +396,13 @@ local function CreateFrame_TradeSkillLink()
"ClassicAPITradeSkillLinkDetailScroll", f, DETAIL_SCROLL_TEMPLATE);
detailScroll:SetSize(LO.detail[3], LO.detail[4]);
detailScroll:SetPoint("TOPLEFT", LO.detail[1], LO.detail[2]);
detailScroll.scrollBarHideable = 1; -- template sets this too; belt-and-suspenders
f.detailScroll = detailScroll;
-- Off until a recipe overflows the pane (toggled in RenderDetail).
-- The template shows its slider and enables the wheel by default; on
-- the roomy Turtle pane that leaves a dead scrollbar that still eats
-- wheel events and over-scrolls a hair. Start both disabled.
-- Bar visibility + wheel are driven per-render by SetDetailBarShown (from
-- the computed overflow). `scrollBarHideable` still lets the template's
-- ScrollFrame_OnScrollRangeChanged manage the slider on range changes.
-- Start with the wheel off; the first RenderDetail/ClearDetail sets it.
detailScroll.scrollBarHideable = 1;
detailScroll:EnableMouseWheel(false);
local detailSB = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if detailSB then
detailSB:Hide();
end
-- The template re-shows its slider when the frame transitions to shown
-- (on first open, before the auto-selected recipe's RenderDetail runs),
-- leaving a dead scrollbar until the first click. Re-assert the hidden
-- state on show unless the current recipe genuinely overflows.
detailScroll:SetScript("OnShow", function()
f:UpdateDetailScrollbar();
RunNextFrame(function()
if f:IsShown() then
f:UpdateDetailScrollbar();
end
end);
end);
f.detailScroll = detailScroll;
local detailChild = CreateFrame("Frame",
"ClassicAPITradeSkillLinkDetailChild", detailScroll);
@@ -514,18 +501,20 @@ local function CreateFrame_TradeSkillLink()
return btn;
end
-- Show the detail slider + enable the wheel only when the current
-- recipe overflows the pane (tracked by `detailNeedScroll`).
function f:UpdateDetailScrollbar()
local need = self.detailNeedScroll and true or false;
self.detailScroll:EnableMouseWheel(need);
-- Show the detail bar/borders only when the content overflows the pane.
-- Driven by our own computed overflow, NOT the engine: scrollBarHideable's
-- auto-hide only fires on a range CHANGE (so content that fits from the
-- start never hides), and GetVerticalScrollRange isn't reliably readable
-- the same tick as UpdateScrollChildRect.
function f:SetDetailBarShown(show)
self.detailScroll:EnableMouseWheel(show);
local base = "ClassicAPITradeSkillLinkDetailScroll";
local sb = getglobal(base .. "ScrollBar");
if sb then sb:SetShown(need) end
if sb then sb:SetShown(show) end
local top = getglobal(base .. "Top");
if top then top:SetShown(need) end
if top then top:SetShown(show) end
local bottom = getglobal(base .. "Bottom");
if bottom then bottom:SetShown(need) end
if bottom then bottom:SetShown(show) end
end
function f:RenderDetail(entry)
@@ -571,19 +560,21 @@ local function CreateFrame_TradeSkillLink()
local total = table.getn(reagents);
for i = 1, total do
local btn = AcquireReagent(i);
local r = reagents[i];
local name, tex, link = itemInfo(r.itemID);
local reagent = reagents[i];
local name, tex, link = itemInfo(reagent.itemID);
btn:SetNormalTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark");
btn.itemLink = link;
btn.nameText:SetText(name or ("item:" .. r.itemID));
local need = r.count or 1;
local have = C_Item.GetItemCount(r.itemID) or 0;
btn.nameText:SetText(name or ("item:" .. reagent.itemID));
local need = reagent.count or 1;
local have = C_Item.GetItemCount(reagent.itemID) or 0;
if have >= need then
btn:GetNormalTexture():SetVertexColor(1, 1, 1);
btn.nameText:SetTextColor(HIGHLIGHT_FONT_COLOR:GetRGB());
local r, g, b = HIGHLIGHT_FONT_COLOR:GetRGB()
btn:GetNormalTexture():SetVertexColor(r, g, b);
btn.nameText:SetTextColor(r, g, b);
else
btn:GetNormalTexture():SetVertexColor(0.5, 0.5, 0.5);
btn.nameText:SetTextColor(GRAY_FONT_COLOR:GetRGB());
local r, g, b = GRAY_FONT_COLOR:GetRGB()
btn:GetNormalTexture():SetVertexColor(r, g, b);
btn.nameText:SetTextColor(r, g, b);
end
local haveText = (have >= 100) and "*" or tostring(have);
btn.count:SetText(haveText .. " /" .. need);
@@ -594,29 +585,32 @@ local function CreateFrame_TradeSkillLink()
self.reagents[i]:Hide();
end
-- Grow the scroll child to the content height so the pane scrolls
-- once the header + reagent rows exceed the visible area. Reagent
-- row 0 sits ~67px below the child top (icon + label); add the
-- wrapped description height for spell recipes.
local descH = (descText ~= "") and self.prodDesc:GetHeight() or 0;
local rows = math.ceil(total / 2);
local contentH = 67 + descH + rows * REAGENT_ROW_HEIGHT + 8;
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
-- Reset to the top and manage the slider explicitly: a range that
-- stays 0 (content fits, e.g. Turtle's roomy pane) never fires the
-- range-changed event that `scrollBarHideable` relies on, so the
-- slider would otherwise sit visible-but-useless. Show it only when
-- the content actually overflows.
self.detailNeedScroll = contentH > LO.detail[4];
local function applyContentHeight()
local contentH = LO.detail[4];
if total > 0 then
local childTop = self.detailChild:GetTop();
local lastBtn = self.reagents[total];
local lastBottom = lastBtn and lastBtn:GetBottom();
if childTop and lastBottom then
-- +22: ~12px name-plate overhang below the icon + ~10px pad.
contentH = (childTop - lastBottom) + 22;
end
end
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
-- Recompute the scroll range from the child's new height, then
-- show the bar only if the content actually overflows the pane.
self.detailScroll:UpdateScrollChildRect();
self:SetDetailBarShown(contentH > LO.detail[4] + 0.5);
end
applyContentHeight();
local sb = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if sb then
sb:SetValue(0);
end
self.detailScroll:SetVerticalScroll(0);
self:UpdateDetailScrollbar();
RunNextFrame(function()
if self:IsShown() then
self:UpdateDetailScrollbar();
if self:IsShown() and self.selectedSpellID == entry.spellID then
applyContentHeight();
end
end)
end
@@ -639,8 +633,8 @@ local function CreateFrame_TradeSkillLink()
self.reagents[i]:Hide();
end
self.detailChild:SetHeight(LO.detail[4]);
self.detailNeedScroll = false;
self:UpdateDetailScrollbar();
self.detailScroll:UpdateScrollChildRect();
self:SetDetailBarShown(false);
self.detailScroll:SetVerticalScroll(0);
end
+3 -1
View File
@@ -21,6 +21,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
@@ -29,7 +30,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
| [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` |
@@ -61,6 +62,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [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` |
| [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` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
+137
View File
@@ -95,6 +95,7 @@ build instructions.
- [Events](#events)
- [`C_EventUtils.IsEventValid(eventName)`](#c_eventutilsiseventvalideventname)
- [`GetFramesRegisteredForEvent(event)`](#getframesregisteredforeventevent)
- [`PLAYER_ENTERING_WORLD` payload (`isInitialLogin`, `isReloadingUi`)](#player_entering_world-payload-isinitiallogin-isreloadingui)
- [`PLAYER_TOTEM_UPDATE` event](#player_totem_update-event)
- [`BAG_UPDATE_DELAYED` event](#bag_update_delayed-event)
@@ -334,6 +335,9 @@ build instructions.
- [`C_MerchantFrame.IsMerchantItemRefundable(slot)`](#c_merchantframeismerchantitemrefundableslot)
- [`C_MerchantFrame.IsSellAllJunkEnabled()`](#c_merchantframeissellalljunkenabled)
- [ChatBubbles](#chatbubbles)
- [`C_ChatBubbles.GetAllChatBubbles([includeForbidden])`](#c_chatbubblesgetallchatbubblesincludeforbidden)
- [NamePlate](#nameplate)
- [`C_NamePlate.GetNamePlates()`](#c_nameplategetnameplates)
- [`C_NamePlate.GetNamePlateGUIDs()`](#c_nameplategetnameplateguids)
@@ -445,6 +449,14 @@ build instructions.
- [`GetTalentSpellID(tabIndex, talentIndex, [rank])`](#gettalentspellidtabindex-talentindex-rank)
- [`GetTalentIDByIndex(tabIndex, talentIndex)`](#gettalentidbyindextabindex-talentindex)
- [Targeting](#targeting)
- [`GetPlayerFacing()`](#getplayerfacing)
- [`TargetDirectionEnemy(facing [, coneAngle])`](#targetdirectionenemyfacing--coneangle)
- [`TargetDirectionFriend(facing [, coneAngle])`](#targetdirectionfriendfacing--coneangle)
- [`TargetNearest([reverse])`](#targetnearestreverse)
- [`TargetNearestEnemyPlayer([reverse])`](#targetnearestenemyplayerreverse)
- [`TargetNearestFriendPlayer([reverse])`](#targetnearestfriendplayerreverse)
- [TaxiMap](#taximap)
- [`C_TaxiMap.GetTaxiNodesForMap([mapID])`](#c_taximapgettaxinodesformapmapid)
- [`C_TaxiMap.GetAllTaxiNodes([uiMapID])`](#c_taximapgetalltaxinodesuimapid)
@@ -2235,6 +2247,27 @@ SuperWoWhook patches the binary's event names with its own. This matches
what `frame:RegisterEvent` will actually accept, which is what addon code
needs to know.
### `GetFramesRegisteredForEvent(event)`
Returns the frames currently registered for `event`, as multiple return
values (matching the modern signature — not a table). Returns nothing when
no frame is registered or the event name is unknown.
```lua
local f1, f2 = GetFramesRegisteredForEvent("PLAYER_LOGIN")
for i = 1, select("#", GetFramesRegisteredForEvent("BAG_UPDATE")) do
local frame = select(i, GetFramesRegisteredForEvent("BAG_UPDATE"))
print(frame:GetName())
end
```
Reads the engine's own subscriber chain — the exact list
`frame:RegisterEvent` appends to — so it stays in sync with real
registrations, including our `AutoReserve`-backed custom events. Frames
created purely in C++ (nameplates, etc.) come back as their canonical
wrapper, so `:GetName()` and other methods work; an anonymous frame's
`:GetName()` is `nil`, same as on retail.
### `PLAYER_ENTERING_WORLD` payload (`isInitialLogin`, `isReloadingUi`)
Backports the 8.0.1 payload onto vanilla's (natively arg-less)
@@ -8012,6 +8045,42 @@ No retail equivalent (retail ships only the explored getter; its inverse,
`C_Map.GetMapOverlays`, returns *everything*). Same table shape and
`areaID` semantics as the explored getter.
## ChatBubbles
### `C_ChatBubbles.GetAllChatBubbles([includeForbidden])`
Returns a 1-based table of the currently-active chat-bubble `Frame`
objects (the speech bubbles above characters who `/say` or `/yell`).
Modern WoW added this in 7.2.5 to replace the old "iterate over
`WorldFrame` children and guess which are bubbles" idiom — we hand you
the exact set instead.
```lua
for _, bubble in ipairs(C_ChatBubbles.GetAllChatBubbles()) do
-- The spoken text lives in the bubble's FontString region.
for _, region in ipairs({bubble:GetRegions()}) do
if region.GetText and region:GetText() then
print(region:GetText())
end
end
end
```
Vanilla 1.12 chat bubbles are `CGChatBubbleFrame`s — full frames the
engine creates in C++ without ever calling `CreateFrame` (same as
default nameplates). The returned frames are the engine's **canonical
wrappers**: real, method-capable (`:GetRegions()`, `:IsShown()`,
`:GetPoint()`, …), and identical to the object any other addon that
touches the bubble sees, so decorations survive. The spoken-text
FontString is parented to the bubble, so it shows up in
`:GetRegions()`.
`includeForbidden` is accepted for signature parity and ignored —
vanilla has no forbidden frames, so every active bubble is returned
regardless. Returns an empty table when no bubbles are showing. A
bubble whose owner just despawned can linger for one frame before the
engine prunes it; filter on `bubble:IsShown()` if that matters.
## NamePlate
Modern `C_NamePlate.*` returns nameplate `Frame` objects keyed off
@@ -10935,6 +11004,74 @@ a different field.
Equivalent to the talentID return slot of `GetTalentInfo` in modern
WoW (5.0+; not exposed at all in 1.12).
## Targeting
Backports of the post-vanilla `TargetScript` selection functions. Vanilla
already ships `TargetNearestEnemy/Friend/PartyMember/RaidMember`,
`TargetLastEnemy/Target`, `AssistUnit`, `TargetUnit`, `ClearTarget`; these
fill in the ones added in later patches. They're built on the engine's own
tab-targeting core — the same visible-unit enumeration, per-mode validity
predicate (reaction/attackability/alive), and selection commit the native
`TargetNearestEnemy` uses — so hostility semantics match the client exactly.
Not backported (no vanilla equivalent — soft-target / action-camera era):
`TargetPriorityHighlightStart/End`, `IsTargetLoose`, `TargetToggle`.
### `GetPlayerFacing()`
Returns the player's facing as an angle in radians (`0` … `2*pi`,
increasing counter-clockwise), or nothing off-world (glue / loading).
Vanilla never shipped this (only character-create/select facing exist), but
it's the companion the direction-target functions below need — pass it to
aim "in front of me."
```lua
local facing = GetPlayerFacing() -- e.g. 1.65
```
Read from the player unit's movement-block orientation field
(`player + 0x9C4`).
### `TargetDirectionEnemy(facing [, coneAngle])`
Targets the nearest **attackable** unit within a cone centered on `facing`
(an absolute world angle in radians, as returned by `GetPlayerFacing()`).
`coneAngle` is the full cone width in radians; omitted, it defaults to
`pi/2` (90° — 45° to either side).
```lua
-- Target whatever enemy you're looking at:
TargetDirectionEnemy(GetPlayerFacing())
-- Wider 180° sweep in front:
TargetDirectionEnemy(GetPlayerFacing(), math.pi)
```
Does nothing if no enemy falls inside the cone. "Attackable" uses the
engine's own reaction check, so it matches what `TargetNearestEnemy` would
consider a valid enemy (hostile, alive, not a critter).
### `TargetDirectionFriend(facing [, coneAngle])`
Same as `TargetDirectionEnemy`, but selects the nearest **friendly**
(assistable) unit in the cone.
### `TargetNearest([reverse])`
Cycles the nearest unit you can attack or assist. Repeated calls within ~1s
step through candidates nearest→farthest (a frozen snapshot, so the order
is stable mid-cycle); a longer gap or an externally-changed target rebuilds
and re-targets the nearest. `reverse` (truthy) steps the other way.
### `TargetNearestEnemyPlayer([reverse])`
Like `TargetNearest`, but restricted to hostile **players** (PvP targeting).
Same cycle behavior and `reverse` flag.
### `TargetNearestFriendPlayer([reverse])`
Like `TargetNearestEnemyPlayer`, but restricted to friendly **players**.
## TaxiMap
Backports of the retail taxi-map node enumerators, reading
+33
View File
@@ -463,6 +463,30 @@ enum Offsets {
// (lo) and `guid[1]` (hi) through the pointer.
FUN_TARGET_BY_GUID = 0x00489A40,
// Tab-targeting internals, shared with our backported TargetNearest* /
// TargetDirection* family (`target/Nearest.cpp`).
//
// `FUN_TARGET_CANDIDATE_VALID` (0x00493E40) — the engine's per-mode
// candidate validity predicate used by the native `TargetNearestEnemy`
// / `TargetNearestFriend` cycle. `__fastcall(CGUnit *player /*ecx*/,
// CGUnit *candidate /*edx*/, int mode) → int` (nonzero = valid). Modes:
// 1 = enemy (attackable + alive + not-critter), 2 = friend (assistable +
// alive), 3 = party, 4 = raid. Wraps the reaction cores
// `FUN_00606980` (can-attack) / `FUN_006066f0` (can-assist), so calling
// it gives us the engine's exact hostility semantics for free.
FUN_TARGET_CANDIDATE_VALID = 0x00493E40,
// `FUN_SET_SELECTION_BY_GUID` (0x00493540) — canonical target setter the
// native tab-cycle commits through. `__cdecl(uint32_t guidLo, uint32_t
// guidHi)`. Validates + fires CMSG_SET_SELECTION + client target +
// event 0xC3, and maintains the current-selection globals below.
// Passing {0,0} clears the target.
FUN_SET_SELECTION_BY_GUID = 0x00493540,
// Current target-selection GUID (lo/hi), written by
// `FUN_SET_SELECTION_BY_GUID`. We read it to detect whether the player's
// target changed out from under an in-progress tab-cycle.
VAR_CURRENT_SELECTION_GUID_LO = 0x00B4E2D8,
VAR_CURRENT_SELECTION_GUID_HI = 0x00B4E2DC,
// Local-player CGObject-like global. Not the same pointer as
// ResolveUnitToken("player") returns — that one's the canonical
// CGPlayer_C used by inventory etc. This pointer's +0xC0 field
@@ -1476,6 +1500,15 @@ enum Offsets {
// `0x0048BACF` / `0x0048BADC`.
OFF_CGOBJECT_VTBL_GET_POSITION = 0x14,
// CGUnit movement-block orientation (facing) in radians, 0..2*pi. Lives
// right after the {x,y,z} position block (at +0x9B8), i.e. pos+0xC. The
// `GetPosition` virtual copies only x/y/z into the caller's buffer, so
// read facing off the object directly. Backs `GetPlayerFacing`. Empirically
// located by probing the player object across a known 180-degree turn
// (value flipped by ~pi, stayed in range); mirrored at +0x9F8 / +0xC98
// (last-sent / render copies) — +0x9C4 is the authoritative one.
OFF_UNIT_FACING = 0x9C4,
// CGObject `m_objectType` field — 0 = (base) object, 1 = item,
// 2 = container, 3 = unit, 4 = player, 5 = gameobject, … Read as
// `*(int *)(obj + 0x14)`. (Distinct from GET_POSITION above, which
+82
View File
@@ -0,0 +1,82 @@
// 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/>.
// `C_ChatBubbles.GetAllChatBubbles([includeForbidden])` — returns an
// array of the currently-active chat-bubble Frame objects, matching
// modern WoW's 7.2.5 signature. Addons use it to recolor / translate /
// reposition speech bubbles by reading the bubble's FontString region.
//
// Vanilla 1.12 chat bubbles are `CGChatBubbleFrame`s — full
// `CSimpleFrame`s created purely in C++ (never via `CreateFrame`), so
// they have no name and no Lua wrapper until something pushes them. This
// is exactly the default-nameplate case: we push each via the shared
// `UI::FrameObject::Push`, which delegates to the engine's own
// `FrameScript_Object::ScriptRegister` to lazily build the canonical
// wrapper. The returned frames respond to real frame methods — the
// FontString holding the spoken text (bubble + 0x31c, created with the
// bubble as its parent) shows up in `bubble:GetRegions()`, so the modern
// "iterate GetRegions, find the FontString, read GetText()" idiom works
// unchanged. This is the pre-7.2.5 "walk WorldFrame children" pattern the
// wiki describes, but handed the exact set instead of the whole world.
//
// The engine keeps active bubbles on an intrusive Storm `TSList` whose
// head is `VAR_CHAT_BUBBLE_LIST_HEAD` (`0x00B6F038`); each node's forward
// link sits at `+0x318` and the list terminator carries the low-bit
// sentinel. We mirror the engine's own walk in the per-frame update
// `FUN_004b1060` / teardown `FUN_004b0f80` verbatim. Bubbles whose owner
// unit has despawned are pruned on the engine's next update tick, so a
// just-orphaned bubble can linger one frame — harmless, and addons that
// care filter on `bubble:IsShown()` (a real method here) anyway.
//
// `includeForbidden` is accepted and ignored: vanilla has no forbidden
// frames, so every bubble is returned regardless.
#include "Game.h"
#include "ui/FrameObject.h"
#include <cstdint>
namespace ChatBubble::Info {
namespace {
// Storm TSList of live CGChatBubbleFrames. Head pointer, per-node
// forward link, and the end-of-list low-bit sentinel — see the engine
// walks at 0x004b1060 / 0x004b0f80.
constexpr uintptr_t kListHead = 0x00B6F038;
constexpr int kOffNextLink = 0x318;
} // namespace
static int __fastcall Script_GetAllChatBubbles(void *L) {
Game::Lua::NewTable(L);
uintptr_t node = *reinterpret_cast<const uintptr_t *>(kListHead);
int nextIndex = 1;
while (node != 0 && (node & 1) == 0) {
Game::Lua::PushNumber(L, static_cast<double>(nextIndex++));
UI::FrameObject::Push(L, reinterpret_cast<void *>(node));
Game::Lua::SetTable(L, -3);
node = *reinterpret_cast<const uintptr_t *>(node + kOffNextLink);
}
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_ChatBubbles", "GetAllChatBubbles",
&Script_GetAllChatBubbles);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace ChatBubble::Info
+98
View File
@@ -0,0 +1,98 @@
// 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/>.
// `GetFramesRegisteredForEvent(event)` → the Frame objects currently
// subscribed to `event`, returned as multiple return values (matching
// modern WoW's vararg signature — not a table). Empty result set returns
// nothing.
//
// The engine already keeps exactly this list: every `frame:RegisterEvent`
// appends the frame to a subscriber chain hanging off the matching event
// table entry. We find the entry by name (same table `Event::Custom`
// walks), then walk the chain the same way `Frame::RegisterEvent`
// (`FUN_00702140`) does — node next at `+0x04`, subscriber frame at
// `+0x08`, with a low-bit-set / null value marking the end sentinel — and
// push each frame via the shared `UI::FrameObject::Push` so callers get
// the canonical wrapper table (real frame methods, `:GetName()`, etc.).
//
// Event names resolve through `Event::Custom::LookupByName`, so this also
// works for our `AutoReserve`-claimed custom events, not just engine ones.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "ui/FrameObject.h"
#include <cstdint>
namespace Event::Registered {
namespace {
int __fastcall Script_GetFramesRegisteredForEvent(void *L) {
if (!Game::Lua::IsString(L, 1))
return 0;
const char *event = Game::Lua::ToString(L, 1);
if (event == nullptr)
return 0;
const int slot = Event::Custom::LookupByName(event);
if (slot < 0)
return 0;
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
if (base == nullptr)
return 0;
const uint8_t *entry = base + slot * Offsets::EVENT_ENTRY_STRIDE;
const uintptr_t head = *reinterpret_cast<const uintptr_t *>(
entry + Offsets::OFF_EVENT_ENTRY_HEAD);
// First pass: count subscribers so we can reserve stack up front — an
// event with many registered frames would otherwise risk overflowing
// the default Lua stack slack as we push.
int count = 0;
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
node = *reinterpret_cast<const uintptr_t *>(
node + Offsets::OFF_EVENT_NODE_NEXT)) {
if (*reinterpret_cast<void *const *>(node + Offsets::OFF_EVENT_NODE_FRAME))
++count;
}
if (count == 0)
return 0;
Game::Lua::CheckStack(L, count);
int pushed = 0;
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
node = *reinterpret_cast<const uintptr_t *>(
node + Offsets::OFF_EVENT_NODE_NEXT)) {
void *frame = *reinterpret_cast<void *const *>(
node + Offsets::OFF_EVENT_NODE_FRAME);
if (frame != nullptr) {
UI::FrameObject::Push(L, frame);
++pushed;
}
}
return pushed;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetFramesRegisteredForEvent",
&Script_GetFramesRegisteredForEvent);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Event::Registered
+7 -66
View File
@@ -56,6 +56,7 @@
#include "Offsets.h"
#include "guid/Guid.h"
#include "nameplate/Walk.h"
#include "ui/FrameObject.h"
#include <cstdint>
@@ -66,9 +67,6 @@ namespace {
using NamePlate::Walk::ForEachNamePlatedUnit;
using NamePlate::Walk::kOffUnitNamePlate;
using LuaRawGetI_t = void(__fastcall *)(void *L, int idx, int n);
using ScriptRegister_t = void(__fastcall *)(void *this_, void *edx_unused,
const char *name);
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
uint32_t guidLo, uint32_t guidHi,
@@ -115,70 +113,13 @@ static int __fastcall Script_GetNamePlateGUIDs(void *L) {
// frame-push path. Internal callers (Script_GetNamePlates etc.) call
// it through the unqualified name (they live in the same namespace).
//
// Delegate wrapper construction to the engine's own
// `FrameScript_Object::ScriptRegister`. First call for a never-seen
// nameplate builds a `{[0] = lightuserdata}` table with the
// framescript metatable, `luaL_ref`s it into the registry, and
// writes the refkey to `nameplate + 0x08` (`OFF_COBJECT_LUA_REGISTRY_REF`).
// Subsequent calls find a populated refkey and just rawgeti.
//
// Why delegate instead of building our own wrapper:
//
// - **Single source of truth.** Every code path that pushes this
// frame — ours via this function, addons' `CreateFrame("Type",
// "name", plate)` extracting the parent, the engine's own internal
// pushers — funnels through `rawgeti(REGISTRY, plate+0x08)` and
// gets the same Lua table. Earlier "build our own wrapper +
// `luaL_ref` it" approach kept a side cache, but pfUI's
// `CreateFrame("Button", "name", parent)` inside `NAME_PLATE_CREATED`
// ends up causing the engine to register the parent itself —
// the engine's wrapper diverges from ours, and later
// `GetNamePlateForUnit` calls return the engine's bare wrapper
// instead of our decorated one. Addon fields on the wrapper
// (`plate.nameplate = styledButton`) disappear from the API's
// perspective even though they still exist on the orphaned
// table.
//
// - **Refcount-pinning is benign here.** `ScriptRegister`
// unconditionally increments `nameplate + 0x04`, which normally
// keeps the engine from GC-ing the CObject. Nameplates are
// pool-managed and never destroyed during a session, so pinning
// doesn't actually leak anything. We call `ScriptRegister` at
// most once per nameplate pointer (gated by `refKey <= 0`), so
// the refcount tops out at one increment per pool slot.
//
// Defensive fallback: if the registry slot exists but isn't a table
// any more (some other code freed it), we re-register. This shouldn't
// happen in practice — engines only unref on frame destruction —
// but a stale type check keeps callers from ever seeing a non-table.
// Thin wrapper over the shared `UI::FrameObject::Push` — nameplates and
// chat bubbles are both engine-created-in-C++ `CSimpleFrame`s that need
// the same lazy `ScriptRegister` → `rawgeti` push so addons get the
// engine's canonical wrapper (see that helper for the pfUI-divergence
// rationale that motivated delegating to the engine).
void PushNamePlateFrame(void *L, void *nameplate) {
auto rawgeti = reinterpret_cast<LuaRawGetI_t>(
Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
auto scriptRegister = reinterpret_cast<ScriptRegister_t>(
static_cast<uintptr_t>(Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER));
int refKey = *reinterpret_cast<const int *>(
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
if (refKey <= 0) {
scriptRegister(nameplate, nullptr, nullptr);
refKey = *reinterpret_cast<const int *>(
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
}
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
return;
Game::Lua::SetTop(L, -2);
// Slot got freed somewhere — re-register to rebuild a fresh
// wrapper and slot. Zero the refcount so `ScriptRegister`
// re-enters its build branch.
*reinterpret_cast<int *>(
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REFCOUNT) = 0;
scriptRegister(nameplate, nullptr, nullptr);
refKey = *reinterpret_cast<const int *>(
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
UI::FrameObject::Push(L, nameplate);
}
// GUID → nameplate Frame pushed on stack. Pushes nil if the GUID
+55
View File
@@ -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/>.
// `GetPlayerFacing()` → facing angle of the player in radians (0 =
// north/+Y in WoW's world convention, increasing counter-clockwise), or
// nothing when there's no player (glue / loading). Vanilla 1.12 doesn't
// ship it (only character-create/select facing exist), but it's the
// missing half that makes our backported `TargetDirectionEnemy/Friend`
// usable — a caller passes `GetPlayerFacing()` to target what's in front.
//
// The orientation is a field in the player's CGUnit movement block
// (`OFF_UNIT_FACING` = +0x9C4), sitting just past the {x,y,z} position at
// +0x9B8. The `GetPosition` virtual only copies x/y/z into a caller buffer,
// so the facing is read off the object directly.
#include "Game.h"
#include "Offsets.h"
#include "unit/Position.h"
#include <cstdint>
namespace Player::Facing {
namespace {
int __fastcall Script_GetPlayerFacing(void *L) {
void *player = Unit::Position::ResolveToken("player");
if (player == nullptr)
return 0;
const float facing = *reinterpret_cast<const float *>(
static_cast<const uint8_t *>(player) + Offsets::OFF_UNIT_FACING);
Game::Lua::PushNumber(L, static_cast<double>(facing));
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetPlayerFacing", &Script_GetPlayerFacing);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Player::Facing
+328
View File
@@ -0,0 +1,328 @@
// 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/>.
// Backports the post-vanilla TargetScript selection functions that 1.12.1
// doesn't ship, built on the engine's own tab-targeting internals:
//
// TargetNearest() — cycle nearest attackable/assistable unit
// TargetNearestEnemyPlayer([rev]) — cycle nearest hostile player (PvP)
// TargetNearestFriendPlayer([rev]) — cycle nearest friendly player
// TargetDirectionEnemy(facing[,cone]) — nearest hostile in a facing cone
// TargetDirectionFriend(facing[,cone]) — nearest friendly in a facing cone
//
// Vanilla already ships `TargetNearestEnemy/Friend/PartyMember/RaidMember`;
// they run through `FUN_00493f60(reverse, mode)` — enumerate visible units,
// keep those `FUN_00493e40(player, cand, mode)` accepts, sort by distance,
// cycle, and commit via `FUN_00493540(guidLo, guidHi)`. We reuse the two
// load-bearing pieces of that machinery directly:
//
// * `FUN_00493e40` (Offsets::FUN_TARGET_CANDIDATE_VALID) — the exact
// per-mode validity predicate (mode 1 = enemy: attackable + alive +
// not-critter; mode 2 = friend: assistable + alive). Calling it gives
// us the engine's real reaction/hostility semantics with zero
// reimplementation.
// * `FUN_00493540` (Offsets::FUN_SET_SELECTION_BY_GUID) — the canonical
// setter the native cycle commits through (CMSG_SET_SELECTION + client
// target + event 0xC3), which also maintains the current-selection
// globals we read back for cycle continuity.
//
// So the only genuinely new logic here is (a) an extra player-GUID filter
// for the `*Player` variants and (b) the cone geometry for the direction
// variants — everything reaction-related defers to the engine.
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "unit/Position.h"
#include <cmath>
#include <cstdint>
namespace Target::Nearest {
namespace {
// Candidate class each function selects from.
enum class Filter { Enemy, Friend, EnemyPlayer, FriendPlayer, Any };
constexpr int kFilterCount = 5;
// Default cone (full width, radians) when `coneAngle` is omitted — 90°, so
// 45° to either side of the facing. The engine's own default constant
// wasn't cleanly recoverable from the 3.3.5 reference binary; this is a
// deliberate, documented choice that matches the "roughly in front of me"
// intent of a direction-target keybind.
constexpr float kDefaultConeAngle = 1.5707963f; // pi/2
// Re-cycle window: repeated presses within this many ms advance through the
// frozen candidate snapshot (no ping-pong as distances shift); a longer gap
// rebuilds and re-targets the nearest. Mirrors the ~1s reset the native
// cycle uses.
constexpr uint32_t kCycleWindowMs = 1000;
constexpr int kMaxCandidates = 64;
// Engine entry points ─────────────────────────────────────────────────
using Predicate_t = int(__fastcall *)(void *player, void *cand, int mode);
using SetTargetByGuid_t = void(__fastcall *)(const uint64_t *guid);
using EnumCallback_t = int(__fastcall *)(void *ctx, void *unusedEdx, uint64_t guid);
using EnumVisibleObjects_t = int(__fastcall *)(EnumCallback_t cb, void *ctx);
using ObjectPtr_t = void *(__fastcall *)(uint32_t typeMask, const char *dbg,
uint32_t guidLo, uint32_t guidHi,
int dbgCode);
using TickMs_t = uint32_t(__fastcall *)();
bool PassesFilter(void *player, void *cand, uint64_t guid, Filter f) {
auto valid = reinterpret_cast<Predicate_t>(Offsets::FUN_TARGET_CANDIDATE_VALID);
switch (f) {
case Filter::Enemy: return valid(player, cand, 1) != 0;
case Filter::Friend: return valid(player, cand, 2) != 0;
case Filter::EnemyPlayer: return Guid::IsPlayer(guid) && valid(player, cand, 1) != 0;
case Filter::FriendPlayer: return Guid::IsPlayer(guid) && valid(player, cand, 2) != 0;
case Filter::Any: return valid(player, cand, 1) != 0 ||
valid(player, cand, 2) != 0;
}
return false;
}
struct Candidate {
uint64_t guid;
float x, y; // world XY (for the direction cone)
float distSq; // 3D squared distance from the player (for nearest)
};
struct CollectCtx {
void *player;
Filter filter;
float px, py, pz;
Candidate list[kMaxCandidates];
int count;
};
int __fastcall CollectCb(CollectCtx *ctx, void * /*edx*/, uint64_t guid) {
if (ctx->count >= kMaxCandidates)
return 1;
// Players and creatures both resolve under TYPEMASK_UNIT (CGPlayer
// derives from CGUnit); non-unit objects resolve to null and drop out.
auto ObjectPtr =
reinterpret_cast<ObjectPtr_t>(Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *cand = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (cand == nullptr || cand == ctx->player)
return 1;
if (!PassesFilter(ctx->player, cand, guid, ctx->filter))
return 1;
float pos[3];
if (!Unit::Position::Read(cand, pos))
return 1;
const float dx = pos[0] - ctx->px;
const float dy = pos[1] - ctx->py;
const float dz = pos[2] - ctx->pz;
Candidate &c = ctx->list[ctx->count++];
c.guid = guid;
c.x = pos[0];
c.y = pos[1];
c.distSq = dx * dx + dy * dy + dz * dz;
return 1;
}
// Enumerate visible units matching `f` into `out`. Returns false (empty)
// pre-world or on a player-position miss.
bool Collect(Filter f, CollectCtx *out) {
out->count = 0;
// The enumerator derefs the object manager unconditionally; null on the
// glue / character-select screen.
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) ==
nullptr)
return false;
void *player = Unit::Position::ResolveToken("player");
float ppos[3];
if (!Unit::Position::Read(player, ppos))
return false;
out->player = player;
out->filter = f;
out->px = ppos[0];
out->py = ppos[1];
out->pz = ppos[2];
auto Enum = reinterpret_cast<EnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
Enum(reinterpret_cast<EnumCallback_t>(&CollectCb), out);
return true;
}
// Commit the selection through the engine's `Script_TargetUnit` helper
// rather than the tab-cycle's raw `FUN_00493540`: this one first checks the
// GUID resolves to a live unit and bails otherwise, so a stale GUID left in
// a cycle snapshot (unit despawned / went out of range between presses)
// gets rejected instead of committed. Committing a dead GUID would leave
// `"target"` pointing at freed unit data and crash the next handler that
// reads it. `FUN_TARGET_BY_GUID` still routes through `FUN_00493540`
// internally, so the current-selection globals we read back are maintained.
void SetTarget(uint64_t guid) {
reinterpret_cast<SetTargetByGuid_t>(Offsets::FUN_TARGET_BY_GUID)(&guid);
}
uint64_t CurrentSelection() {
const uint32_t lo = *reinterpret_cast<volatile uint32_t *>(
Offsets::VAR_CURRENT_SELECTION_GUID_LO);
const uint32_t hi = *reinterpret_cast<volatile uint32_t *>(
Offsets::VAR_CURRENT_SELECTION_GUID_HI);
return (static_cast<uint64_t>(hi) << 32) | lo;
}
uint32_t NowMs() {
return reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)();
}
// Ascending insertion sort by 3D distance (candidate counts are tiny).
void SortByDistance(Candidate *a, int n) {
for (int i = 1; i < n; ++i) {
Candidate key = a[i];
int j = i - 1;
while (j >= 0 && a[j].distSq > key.distSq) {
a[j + 1] = a[j];
--j;
}
a[j + 1] = key;
}
}
// Per-filter tab-cycle state. A frozen snapshot of GUIDs (sorted nearest→
// farthest) plus a cursor; presses within `kCycleWindowMs` that haven't had
// the target changed out from under them advance the cursor over the frozen
// list, so the order stays stable through a cycle burst.
struct CycleState {
bool valid = false;
uint64_t snapshot[kMaxCandidates];
int count = 0;
int index = 0;
uint32_t lastPressMs = 0;
uint64_t lastSetGuid = 0;
};
CycleState g_cycle[kFilterCount];
void DoCycle(void *L, Filter f) {
const bool reverse = Game::Lua::ToBoolean(L, 1) != 0;
CycleState &st = g_cycle[static_cast<int>(f)];
const uint32_t now = NowMs();
const bool cont = st.valid && st.count > 0 &&
(now - st.lastPressMs) <= kCycleWindowMs &&
CurrentSelection() == st.lastSetGuid;
if (cont) {
st.index = reverse ? (st.index + st.count - 1) % st.count
: (st.index + 1) % st.count;
} else {
CollectCtx c;
if (!Collect(f, &c) || c.count == 0) {
st.valid = false;
return;
}
SortByDistance(c.list, c.count);
st.count = c.count;
for (int i = 0; i < c.count; ++i)
st.snapshot[i] = c.list[i].guid;
st.index = 0;
st.valid = true;
}
const uint64_t guid = st.snapshot[st.index];
SetTarget(guid);
st.lastSetGuid = guid;
st.lastPressMs = now;
}
void DoDirection(void *L, bool hostile, const char *usage) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, usage);
return;
}
const float facing = static_cast<float>(Game::Lua::ToNumber(L, 1));
float cone = Game::Lua::IsNumber(L, 2)
? static_cast<float>(Game::Lua::ToNumber(L, 2))
: kDefaultConeAngle;
if (cone <= 0.0f)
cone = kDefaultConeAngle;
const float cosHalf = std::cos(cone * 0.5f);
const float dirX = std::cos(facing);
const float dirY = std::sin(facing);
CollectCtx c;
if (!Collect(hostile ? Filter::Enemy : Filter::Friend, &c) || c.count == 0)
return;
int best = -1;
float bestDistSq = 0.0f;
for (int i = 0; i < c.count; ++i) {
const float vx = c.list[i].x - c.px;
const float vy = c.list[i].y - c.py;
const float horizSq = vx * vx + vy * vy;
if (horizSq <= 0.0001f)
continue;
// cos(angle between facing and the horizontal vector to the unit).
const float cosAngle = (dirX * vx + dirY * vy) / std::sqrt(horizSq);
if (cosAngle < cosHalf)
continue; // outside the cone
if (best < 0 || c.list[i].distSq < bestDistSq) {
best = i;
bestDistSq = c.list[i].distSq;
}
}
if (best >= 0)
SetTarget(c.list[best].guid);
}
// Lua entry points ─────────────────────────────────────────────────────
int __fastcall Script_TargetNearest(void *L) {
DoCycle(L, Filter::Any);
return 0;
}
int __fastcall Script_TargetNearestEnemyPlayer(void *L) {
DoCycle(L, Filter::EnemyPlayer);
return 0;
}
int __fastcall Script_TargetNearestFriendPlayer(void *L) {
DoCycle(L, Filter::FriendPlayer);
return 0;
}
int __fastcall Script_TargetDirectionEnemy(void *L) {
DoDirection(L, /*hostile*/ true,
"Usage: TargetDirectionEnemy(facing [, coneAngle])");
return 0;
}
int __fastcall Script_TargetDirectionFriend(void *L) {
DoDirection(L, /*hostile*/ false,
"Usage: TargetDirectionFriend(facing [, coneAngle])");
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("TargetNearest", &Script_TargetNearest);
Game::Lua::RegisterGlobalFunction("TargetNearestEnemyPlayer",
&Script_TargetNearestEnemyPlayer);
Game::Lua::RegisterGlobalFunction("TargetNearestFriendPlayer",
&Script_TargetNearestFriendPlayer);
Game::Lua::RegisterGlobalFunction("TargetDirectionEnemy",
&Script_TargetDirectionEnemy);
Game::Lua::RegisterGlobalFunction("TargetDirectionFriend",
&Script_TargetDirectionFriend);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Target::Nearest
+86
View File
@@ -0,0 +1,86 @@
// 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 "ui/FrameObject.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace UI::FrameObject {
namespace {
using LuaRawGetI_t = void(__fastcall *)(void *L, int idx, int n);
using ScriptRegister_t = void(__fastcall *)(void *this_, void *edx_unused,
const char *name);
} // namespace
// Delegate wrapper construction to the engine's own
// `FrameScript_Object::ScriptRegister`. First call for a never-seen frame
// builds a `{[0] = lightuserdata}` table with the framescript metatable,
// `luaL_ref`s it into the registry, and writes the refkey to `frame +
// 0x08` (`OFF_COBJECT_LUA_REGISTRY_REF`). Subsequent calls find a
// populated refkey and just rawgeti.
//
// Why delegate instead of building our own wrapper: single source of
// truth. Every code path that pushes this frame — ours, addons'
// `CreateFrame(..., parent)` extracting the frame, the engine's own
// pushers — funnels through `rawgeti(REGISTRY, frame + 0x08)` and gets the
// same Lua table, so addon fields set on the wrapper don't get orphaned
// onto a divergent copy. See the long note in `nameplate/Info.cpp`'s
// original implementation for the pfUI-divergence story that motivated
// this.
//
// `ScriptRegister` unconditionally bumps the CObject refcount at `+0x04`,
// which pins the frame against GC. That's benign for the pooled/permanent
// frames we push here (nameplates, chat bubbles) — we call it at most
// once per frame pointer (gated by `refKey <= 0`), so the refcount tops
// out at one increment per pool slot.
//
// Defensive fallback: if the registry slot exists but isn't a table any
// more (freed elsewhere), re-register to rebuild a fresh wrapper. This
// shouldn't happen in practice — engines only unref on frame destruction.
void Push(void *L, void *frame) {
auto rawgeti = reinterpret_cast<LuaRawGetI_t>(
Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
auto scriptRegister = reinterpret_cast<ScriptRegister_t>(
static_cast<uintptr_t>(Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER));
auto *cobj = static_cast<uint8_t *>(frame);
int refKey = *reinterpret_cast<const int *>(
cobj + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
if (refKey <= 0) {
scriptRegister(frame, nullptr, nullptr);
refKey = *reinterpret_cast<const int *>(
cobj + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
}
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
return;
Game::Lua::SetTop(L, -2);
// Slot got freed somewhere — re-register to rebuild a fresh wrapper
// and slot. Zero the refcount so `ScriptRegister` re-enters its build
// branch.
*reinterpret_cast<int *>(cobj + Offsets::OFF_COBJECT_LUA_REFCOUNT) = 0;
scriptRegister(frame, nullptr, nullptr);
refKey = *reinterpret_cast<const int *>(
cobj + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
}
} // namespace UI::FrameObject
+34
View File
@@ -0,0 +1,34 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace UI::FrameObject {
// Pushes a C++ `CFrameScriptObject`-derived frame onto the Lua stack as
// its canonical wrapper table — the same object every other engine path
// resolves via `rawgeti(REGISTRY, frame + 0x08)`, so addon-set fields on
// the wrapper survive roundtrips through our API and method dispatch
// (`:GetRegions()`, `:GetName()`, …) works normally.
//
// For frames the engine created purely in C++ without ever going through
// `CreateFrame` (default nameplates, chat bubbles), the registry ref at
// `+0x08` starts at 0; the first push delegates to the engine's own
// `FrameScript_Object::ScriptRegister` to lazily build the wrapper +
// registry slot. Subsequent pushes just `rawgeti` the cached slot.
//
// Leaves exactly one value (the wrapper table, or the re-registered
// fallback) on the stack. `frame` must be non-null.
void Push(void *L, void *frame);
} // namespace UI::FrameObject