Compare commits

...
26 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
Brues 98b4ae14e7 totem: add GetTotemDuration(slot)
Returns the slot's active totem total duration in seconds (0 when none) —
the totem's full lifetime from cast, same value as GetTotemInfo's 4th
return, companion to GetTotemTimeLeft. Reads the tracker's durationMs.
Verified in-game (constant 45 while time-left counts down).
2026-07-20 21:27:58 -05:00
Brues 8d957eb424 totem: add TargetTotem(slot)
Target the player's totem in a slot (1-4). Looks up the totem creature's
live GUID (object-manager scan for the player-owned creature of the
slot's tracked entry) and sets the target via FUN_00489a40 — the
set-target-by-GUID path Script_TargetUnit uses after its token→GUID
resolve. No-op when the slot is empty or the totem isn't visible. Added
Offsets::FUN_TARGET_BY_GUID.
2026-07-20 21:25:34 -05:00
Brues 78618ea2cc totem: add GetTotemTimeLeft(slot)
Returns seconds remaining before the slot's totem auto-destroys (0 when
no totem is active), computed from the existing Totem::Tracker's
startMs + durationMs. Slots 1-4 = Fire/Earth/Water/Air. Verified in-game
counting down.
2026-07-20 21:11:01 -05:00
Brues 1a4583e16e totem: fire PLAYER_TOTEM_UPDATE on slot changes
Backport the TBC PLAYER_TOTEM_UPDATE(totemSlot) event (absent from
vanilla's table, so reserved as a custom event) on top of the existing
Totem::Tracker. Every slot state change marks a dirty bit — the cast
path (SpellGo), duration expiry, and the WorldTick death-scan removal —
and OnTick flushes the dirty slots with Fire(id, "%d", slot). Flushing
from the tick keeps Lua handlers out of mid-packet context. Drops fire
next frame; removals within the ~250ms scan interval. Verified in-game.
2026-07-20 21:04:15 -05:00
Brues a20e1206fb totem: add GetTotemInfo(slot) with a self-tracking totem subsystem
Vanilla 1.12 tracks nothing about totems client-side (the totem bar,
PLAYER_TOTEM_UPDATE, and GetTotemInfo are all TBC additions), so
Totem::Tracker tracks the four element slots itself — data-driven:

- Slot from the summon spell's Spell.dbc effect (87-90 =
  SUMMON_TOTEM_SLOT1..4 = Fire/Earth/Water/Air; slot = effect - 86).
- Creature entry from the effect's EffectMiscValue (for death detection).
- Duration from SpellDuration.dbc via the engine duration helper.
- haveTotem tool item from the spell's Totem[0] field (0xA0) — the totem
  the spell requires (a tool, not a consumed reagent) — resolved from the
  player's spellbook on SPELLS_CHANGED, not hardcoded.

Casts are observed through Aura::Source's existing SMSG_SPELL_GO hook (a
one-line notify before its aura gate, since a summon applies no aura) —
no new hook, respects one-hook-per-target. Tool items resolve on
SPELLS_CHANGED (a no-arg event via the shared Event::SignalHook), when
the spellbook is populated. A slot clears when its duration elapses OR
when the totem creature disappears early (killed / Totemic Recall /
destroyed), detected by a throttled WorldTick object-manager scan for the
player-owned creature of that entry (seen-then-gone latch; CreatedBy ==
player so another shaman's same-entry totem can't mask yours).

GetTotemInfo returns haveTotem, totemName, startTime, duration, icon,
modRate=1, spellID. Turtle-custom totems are tracked automatically.
Also exposes Item::Count::InInventory (equipped + bags) as a shared
helper for the tool-presence check. Verified in-game: Earthbind lands in
slot 2, haveTotem reflects the Earth Totem tool, and Totemic Recall
clears the slot early.
2026-07-20 20:53:01 -05:00
Brues 9f67cade87 unit: add UnitOwnerGUID(unit)
Returns the GUID string of a unit's owner — the summoner of a
pet/guardian/totem or the charmer of a charmed unit — or nil for
players, world creatures, and unresolved units. Reuses Unit::Pet's
existing OwnerGuid helper (CharmedBy, else CreatedBy in m_objectFields),
the same field the pet predicates share. Verified in-game:
UnitOwnerGUID('target') == UnitGUID('targetowner').
2026-07-20 18:35:47 -05:00
Brues 8629e13e1f gameobject: add ClosestGameObjectPosition(gameObjectID)
Returns xPos, yPos, distance for the nearest visible game object of a
given GO-template ID, or nothing when none is in range — the game-object
counterpart of ClosestUnitPosition.

Extract the shared visible-object-manager scan into
Object::ClosestByEntry (object/ClosestByEntry.{h,cpp}): enumerate visible
objects, match GUID type + template entry (bits 24-47), return the
closest one's world position + center distance. Both ClosestUnitPosition
(TYPEMASK_UNIT / Creature) and ClosestGameObjectPosition
(TYPEMASK_GAMEOBJECT / GameObject) are now thin wrappers over it;
ClosestUnitPosition behavior is unchanged.

Same retail-vs-vanilla caveat as the unit version: returns the nearest
CURRENTLY-VISIBLE object rather than reading retail's static starting-
zone database (which vanilla lacks). Documented in docs/API.md.
2026-07-20 18:26:46 -05:00
Brues ca9bfbab5e unit: add ClosestUnitPosition(creatureID)
Returns xPos, yPos, distance for the nearest creature of a given NPC
entry ID, or nothing when none is in range. Walks the visible-object
manager (Loot::Nearby pattern), matches creatures whose GUID encodes
the entry (bits 24-47, per guid/CreatureInfo), and returns the closest
one's world position + center distance in yards.

Semantic note: retail's ClosestUnitPosition reads a static client spawn
database (starting-zone mobs only); vanilla 1.12 has none, so this
returns the nearest CURRENTLY-VISIBLE creature of that entry (anywhere)
— more general than retail, but can't point at un-synced spawns.
Documented in docs/API.md.
2026-07-20 18:18:52 -05:00
Brues 8fdc767cc7 item: add Enum.ItemQuality
Poor=0 through Artifact=6, matching the numeric quality GetItemInfo /
C_Item.GetItemQuality report. Heirloom (7) and WoWToken (8) are omitted
— no such items exist on 1.12. Registered in src/item/TypeInfo.cpp
alongside Enum.ItemClass / Enum.InventoryType. Docs + README updated.
2026-07-20 17:36:47 -05:00
Brues aa31c25be5 item: add GetItemClassInfo, GetItemSubClassInfo global, Enum.ItemClass
- GetItemClassInfo(classID) -> localized ItemClass.dbc name, registered
  as both a global and C_Item.GetItemClassInfo. Obsolete class slots
  return the client's literal "...(OBSOLETE)" string.
- GetItemSubClassInfo(classID, subClassID) exposed as a global (the
  C_Item form already existed); returns name + the armor-material bool.
- Enum.ItemClass: 0..15 with modern key names on the obsolete slots
  (Gem/ItemEnhancement/CurrencyTokenObsolete/PermanentObsolete), plus
  post-vanilla 16..19 for parity, matching the Enum.InventoryType
  precedent in the same module.

All added to src/item/TypeInfo.cpp alongside the existing subclass /
inventory-type surface. Docs + README updated.
2026-07-20 17:27:28 -05:00
Brues 29aafe88f8 turtle: move combo-finisher & armor-healing custom data out of stock
Two pieces of hardcoded Turtle-specific data were embedded in stock
modules; relocate them to src/turtle/ behind extension points so the
stock code carries only generic mechanism.

- aura/ComboDuration: add an AutoDanglingResolver extension point; the
  stock module keeps the dangling-row detection + scaling formula, and
  Turtle's reworked-Rip base/max values move to
  src/turtle/ComboDuration.cpp. Not gated on Turtle::Detected() — the
  dangling-row condition stays the gate (gate on the bug, not the
  realm), preserving prior behavior exactly.

- spell/BonusDamage: add a Spell::HealingAura resolver extension point;
  the stock GetSpellBonusHealing decoder keeps auras 135 (flat) and 175
  (xSpirit), and Turtle's Ironclad aura 199 (xArmor) moves to
  src/turtle/HealingAura.cpp, gated on Turtle::Detected() (an aura index
  is a generic slot that could collide on another server).

Behavior is unchanged on Turtle; both extractions are pure relocation.
2026-07-20 16:49:45 -05:00
Brues 461f15a4a6 item: derive bag family from (class, subclass) for bags with empty field
Turtle's item data leaves m_bagFamily at 0 on the bags/quivers
themselves — the four profession bags (Soul/Herb/Enchanting/
Engineering, Container class) and quivers/ammo pouches (Quiver
class) all read 0, so C_Item.GetItemFamily and
C_Container.GetContainerNumFreeSlots reported 0 for them even
though the tooltip clearly shows "Soul Bag" etc. Recover the family
from the item's class + subclass instead (keyed on class because
subclass numbers repeat: Container sub 2 = Herb Bag, Quiver sub 2 =
Quiver). Loot items keep carrying the field directly.

Factored the derivation into a shared Item::BagFamily helper
(BitmaskForRecord) used by both GetItemFamily and the free-slot
functions, so a bag and the items it holds always agree on the
family bit. Side effect: CalculateTotalNumberOfFreeBagSlots now
correctly excludes specialty bags from the general free-slot total.

Non-stock data lives in its own module: src/turtle/ with a
TURTLE_WOW_VERSION-based Turtle::Detected() gate (no realm sniffing)
and the custom Meat/Fish/Leather/Mining families (ItemBagFamily.dbc
rows 10-13) plugged into Item::BagFamily via an AutoResolver
extension point, so the stock table stays pure vanilla.
2026-07-20 16:38:42 -05:00
Brues 6245d72694 unit: make UnitInRange reach-aware
Compare center distance against `40 + playerReach + targetReach`
instead of a flat 40, reading each unit's bounding radius from
`[m_objectFields + 0x1F0]` — the same per-unit size factor the
engine's spell-range helper (FUN_006e3480) and the interact/loot
range checks add. A 40-yard heal reaches a target whose center is
slightly past 40 yards but whose body is within reach; the old flat
cap reported those out-of-range. UnitDistanceSquared stays raw
center-to-center, matching retail.
2026-07-20 14:52:35 -05:00
Brues bd14228ad7 event: add PLAYER_ENTERING_WORLD payload (isInitialLogin, isReloadingUi)
Backport the 8.0.1 PEW payload onto vanilla's arg-less PLAYER_ENTERING_WORLD.

PEW is a no-arg event, so it broadcasts through FUN_FIRE_EVENT_NO_ARGS
(__fastcall(int), the no-arg dispatcher) — NOT the variadic FUN_FIRE_EVENT.
So we intercept that dispatcher and, for the PEW event id, suppress the
no-arg fire and re-broadcast through the variadic dispatcher with the two
booleans (true->1 / false->nil, the %d/%s+nullptr trick). Both fire paths
walk the same frame chain, so PEW-registered frames get the args once.

Flags need no PEW-specific engine hook — they fall out of signals we already
produce: GlueModuleAutoRegister (glue-screen boot) latches "next enter-world
is a fresh login"; ModuleAutoRegister (in-game Lua re-init) marks
login-or-reload; a zone/instance transition rebuilds neither, so both false.

New shared Event::SignalHook: FUN_FIRE_EVENT_NO_ARGS was already hooked by
loot/Scan (silent-scan suppression), and MinHook allows one hook per target,
so — like Tick::WorldTick for the per-frame tick — this module now owns the
single hook and fans out to interceptors. loot/Scan refactored from owning
the hook to being a subscriber (behavior identical). Event::EnteringWorld is
a second subscriber.

Verified in-game: fresh login, /reload, and instance transition each report
the correct (isInitialLogin, isReloadingUi).
2026-07-20 03:22:07 -05:00
Brues 5b15172894 coroutine: guard AuxResume xmoves with lua_checkstack
lua_xmove moves values via api_incr_top, which asserts room on the
destination rather than growing it. AuxResume xmoved an unbounded count both
ways without a guard: nres (a coroutine body can yield/return arbitrarily
many values) onto L, and nargs (a caller can resume(co, unpack(bigTable)))
onto co. Moving more than the ~20-slot C-call guarantee corrupted the stack
-- the same latent bug strsplit had, now fixable thanks to the bound
Game::Lua::CheckStack.

Guard both xmoves; on overflow (>2048) push an error and return the error
path (false,msg for resume; re-raise for wrap). Verified in-game: a coroutine
yielding 100 values via unpack now returns all 100 cleanly (previously stack
corruption), and normal small returns still work.
2026-07-20 00:48:28 -05:00
Brues b5470e8d3d lua: add string.reverse/strrev, math.modf, math.huge
Close the remaining 5.0->5.1 string/math stdlib gaps:

- string.reverse(s) / strrev(s) — byte-wise reverse; 5.1 added it, 5.0's
  strlib lacks it. Registered on the string table and as the strrev global.
- math.modf(x) -> (integral, fractional) — integral truncated toward zero,
  fractional keeps x's sign (via C modf). Genuinely new in 5.1 (unlike fmod,
  which 1.12 has as math.mod).
- math.huge — positive-infinity constant (HUGE_VAL) set directly on the math
  table (it's a value, not a function).

All on both Lua states. Verified in-game: reverse, modf on +/- inputs, and
math.huge as a true infinity sentinel.
2026-07-20 00:31:20 -05:00
Brues c601967fdb lua: add strjoin / strtrim / strreplace
Round out the WoW string globals alongside strsplit. These are FrameXML Lua
in 3.3.5 (no C original — no name strings in the binary), so they're
implemented from standard semantics rather than disassembled:

- strjoin(delimiter, ...) — inverse of strsplit; concatenate varargs with the
  delimiter. nil/non-coercible pieces become "" so delimiters stay aligned.
- strtrim(str [, chars]) — trim a literal character set (default whitespace)
  from both ends via a two-ended scan; no allocation, embedded NULs preserved.
- strreplace(str, find, replace) -> (result, count) — plain-text (non-pattern)
  substring replace; empty find returns str unchanged. Not a stock WoW global
  (that build's compat script added it); documented as a ClassicAPI
  convenience complementing gsub.

Registered as globals on both Lua states. Verified in-game: join, empty
delimiter, trim (default + custom set), plain replace with count, and a
strsplit->strjoin round-trip.
2026-07-20 00:21:59 -05:00
Brues 5c063ade73 lua: add strsplit; bind lua_checkstack
Backport the WoW global strsplit(sep, str [, pieces]) to 1.12, ported from
3.3.5's strsplit (FUN_00816a60): split str on any character in sep, returning
the pieces as multiple values; pieces>0 caps the count with the unsplit
remainder as the final piece (0/omitted = unlimited, 1 = whole string).
Consecutive/trailing delimiters yield empty pieces, matching retail.

Two deliberate changes from the 3.3.5 source:
- Skip its lua_settop(L, 0). Popping the args un-roots the source string, so a
  GC step during a pushlstring could free the buffer we're still scanning.
  Leaving the args on the stack keeps the source GC-rooted; Lua returns the
  top N (the pieces) regardless of the args below.
- Bind lua_checkstack (0x006F2F30, __fastcall(L, size) -> int, found via its
  cmp ...,0x800 LUA_MAXCSTACK test) as Game::Lua::CheckStack and guard every
  push with it, erroring "strsplit(): Stack overflow" like 3.3.5 on genuine
  exhaustion (strsplit pushes an unbounded number of results). Reusable for
  any future multi-return C function.

Registered on both Lua states. Verified in-game across basic split, pieces
cap, multi-char separator set, empty middle piece, and empty-separator cases.
2026-07-20 00:05:24 -05:00
48 changed files with 3555 additions and 292 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
+11 -5
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,23 +30,23 @@ 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` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem` (script) |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `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.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`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [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` |
| [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`, `math.fmod`, `select`, `string.gmatch`, `string.match`, `table.wipe` |
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `math.fmod`, `math.huge`, `math.modf`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.wipe` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
@@ -61,11 +62,13 @@ 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` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterComboDuration` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
@@ -144,6 +147,7 @@ when launching with `-console`), not as Lua functions. See the
| `PLAYER_STOPPED_MOVING` | *(none)* |
| `PLAYER_STARTED_TURNING` | *(none)* |
| `PLAYER_STOPPED_TURNING` | *(none)* |
| `PLAYER_TOTEM_UPDATE` | `totemSlot` |
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `QUEST_REMOVED` | `questID` |
@@ -166,6 +170,8 @@ when launching with `-console`), not as Lua functions. See the
| Addon security | `Enum.AddOnSecurityStatus.{Secure,Insecure,Banned,NotAvailable}` |
| Power type | `Enum.PowerType.{HealthCost,None,Mana,Rage,Focus,Energy,Happiness}` |
| Inventory type | `Enum.InventoryType.Index*Type` (0–34, e.g. `IndexHeadType`=1 … `IndexRelicType`=28) |
| Item class | `Enum.ItemClass.{Consumable,Container,Weapon,Gem,Armor,Reagent,Projectile,Tradegoods,ItemEnhancement,Recipe,Quiver,Questitem,Key,Miscellaneous,…}` (0–19) |
| Item quality | `Enum.ItemQuality.{Poor,Common,Uncommon,Rare,Epic,Legendary,Artifact}` (0–6) |
### Unit tokens
+612 -37
View File
@@ -95,6 +95,9 @@ 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)
- [`PLAYER_EQUIPMENT_CHANGED` event](#player_equipment_changed-event)
- [`UPDATE_INVENTORY_DURABILITY` event](#update_inventory_durability-event)
@@ -154,6 +157,7 @@ build instructions.
- [GameObject](#gameobject)
- [`C_GameObjectInfo.GetGameObjectInfoByID(gameObjectID)`](#c_gameobjectinfogetgameobjectinfobyidgameobjectid)
- [`C_GameObjectInfo.RequestLoadGameObjectByID(gameObjectID)`](#c_gameobjectinforequestloadgameobjectbyidgameobjectid)
- [`ClosestGameObjectPosition(gameObjectID)`](#closestgameobjectpositiongameobjectid)
- [GameTooltip](#gametooltip)
- [`GameTooltip:SetSpellByID(spellID)`](#gametooltipsetspellbyidspellid)
@@ -178,6 +182,8 @@ build instructions.
- [`LE_UNIT_STAT_*`](#le_unit_stat_)
- [`Enum.AddOnSecurityStatus`](#enumaddonsecuritystatus)
- [`Enum.InventoryType`](#enuminventorytype)
- [`Enum.ItemClass`](#enumitemclass)
- [`Enum.ItemQuality`](#enumitemquality)
- [`Enum.PowerType`](#enumpowertype)
- [Gossip](#gossip)
@@ -230,7 +236,8 @@ build instructions.
- [`C_Item.GetItemSpell(item)`](#c_itemgetitemspellitem)
- [`C_Item.GetItemStatDelta(itemLink1, itemLink2)`](#c_itemgetitemstatdeltaitemlink1-itemlink2)
- [`C_Item.GetItemStats(itemLink)`](#c_itemgetitemstatsitemlink)
- [`C_Item.GetItemSubClassInfo(classID, subClassID)`](#c_itemgetitemsubclassinfoclassid-subclassid)
- [`GetItemClassInfo(classID)`](#getitemclassinfoclassid)
- [`GetItemSubClassInfo(classID, subClassID)` / `C_Item.GetItemSubClassInfo(classID, subClassID)`](#getitemsubclassinfoclassid-subclassid--c_itemgetitemsubclassinfoclassid-subclassid)
- [`C_Item.GetItemUniqueness(itemLocation)` / `C_Item.GetItemUniquenessByID(item)`](#c_itemgetitemuniquenessitemlocation--c_itemgetitemuniquenessbyiditem)
- [`C_Item.GetStackCount(itemLocation)`](#c_itemgetstackcountitemlocation)
- [`C_Item.IsBound(itemLocation)`](#c_itemisbounditemlocation)
@@ -282,7 +289,14 @@ build instructions.
- [`select(index, ...)`](#selectindex-)
- [`table.wipe(t)`](#tablewipet)
- [`string.match` / `string.gmatch`](#stringmatch--stringgmatch)
- [`strsplit(sep, str [, pieces])`](#strsplitsep-str--pieces)
- [`strjoin(delimiter, ...)`](#strjoindelimiter-)
- [`strtrim(str [, chars])`](#strtrimstr--chars)
- [`strreplace(str, find, replace)`](#strreplacestr-find-replace)
- [`string.reverse(s)` / `strrev(s)`](#stringreverses--strrevs)
- [`math.fmod(x, y)`](#mathfmodx-y)
- [`math.modf(x)`](#mathmodfx)
- [`math.huge`](#mathhuge)
- [`coroutine.create(fn)`](#coroutinecreatefn)
- [`coroutine.resume(co, ...)`](#coroutineresumeco-)
- [`coroutine.yield(...)`](#coroutineyield)
@@ -321,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)
@@ -432,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)
@@ -449,6 +474,12 @@ build instructions.
- [`C_DateAndTime.GetServerTimeLocal()`](#c_dateandtimegetservertimelocal)
- [`C_DateAndTime.GetSecondsUntilDailyReset()`](#c_dateandtimegetsecondsuntildailyreset)
- [Totem](#totem)
- [`GetTotemInfo(slot)`](#gettoteminfoslot)
- [`GetTotemTimeLeft(slot)`](#gettotemtimeleftslot)
- [`GetTotemDuration(slot)`](#gettotemdurationslot)
- [`TargetTotem(slot)`](#targettotemslot)
- [TradeSkillUI](#tradeskillui)
- [`C_TradeSkillUI.GetTradeSkillListLink()`](#c_tradeskilluigettradeskilllistlink)
- [`C_TradeSkillUI.GetCraftListLink()`](#c_tradeskilluigetcraftlistlink)
@@ -474,10 +505,12 @@ build instructions.
- [`UnitIsMinion(unit)`](#unitisminionunit)
- [`UnitIsPet(unit)`](#unitispetunit)
- [`UnitIsOtherPlayersPet(unit)`](#unitisotherplayerspetunit)
- [`UnitOwnerGUID(unit)`](#unitownerguidunit)
- [`UnitStandState(unit)`](#unitstandstateunit)
- [`UnitInRange(unit)`](#unitinrangeunit)
- [`UnitDistanceSquared(unit)`](#unitdistancesquaredunit)
- [`UnitInLineOfSight(unit)`](#unitinlineofsightunit)
- [`ClosestUnitPosition(creatureID)`](#closestunitpositioncreatureid)
- [`UnitPower(unit [, powerType])` / `UnitPowerMax(unit [, powerType])`](#unitpowerunit--powertype--unitpowermaxunit--powertype)
- [`UnitPowerType(unit)`](#unitpowertypeunit)
@@ -1373,17 +1406,18 @@ for bag = 0, 4 do
end
```
> **Vanilla `bagType` is sparse.** On Turtle WoW (1.12.1), only Light
> Quiver actually carries a populated BagFamily field among the bags
> we tested. Soul Pouch, Enchanting Bag, Herb Pouch, etc. all return
> 0 here even though their classification clearly implies a family —
> the vanilla server data simply doesn't fill the field for those.
> For bag-category discrimination, prefer reading `(class, subClass)`
> from [`C_Item.GetItemInfoInstant`](#c_itemgetiteminfoinstantitem).
> The same field on **individual loot items** (arrows, bullets,
> shards, herbs) IS populated reliably — use
> [`C_Item.GetItemFamily`](#c_itemgetitemfamilyitem) for routing
> decisions.
> **`bagType` is derived for specialty bags.** Bags leave the raw
> BagFamily field empty in Turtle's data (Soul Bag, Herb Bag, quiver,
> ammo pouch, … all read 0). We fall back to deriving the family from the
> bag's class + subclass, so a Soul Bag reports `0x4` here, a quiver
> `0x1`, an ammo pouch `0x2` — the same values
> [`C_Item.GetItemFamily`](#c_itemgetitemfamilyitem) reports for both
> the bag and the items it holds. This also means
> `CalculateTotalNumberOfFreeBagSlots` now correctly excludes specialty
> bags from the general-purpose free-slot total (it previously counted
> them, since the raw field was 0). Note the Turtle custom families
> (Meat/Fish/Leather/Mining, `0x200`–`0x1000`) collide with retail's
> bit meanings — see the `GetItemFamily` family table for the caveat.
> **The bitmask encoding matches modern.** `bagType` is the bit
> position (`1 << (familyID - 1)`), not the raw 1.12-stored familyID,
@@ -2213,6 +2247,86 @@ 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)
`PLAYER_ENTERING_WORLD`:
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_ENTERING_WORLD")
f:SetScript("OnEvent", function()
local isInitialLogin, isReloadingUi = arg1, arg2
if isInitialLogin then -- fresh character login (from char-select)
elseif isReloadingUi then -- /reload
else -- zone / instance transition
end
end)
```
- `isInitialLogin` — `true` on a fresh login (including logging out to
char-select and back in), `nil` otherwise.
- `isReloadingUi` — `true` when the fire is caused by a `/reload`, `nil`
otherwise.
- Both `nil` on an instance/zone transition — matching retail.
Delivered as real event args (`arg1`/`arg2`), so retail-style handlers work
unchanged. Values are `true`→`1` / false→`nil` (the engine dispatcher has no
boolean), so `if isInitialLogin then …` reads naturally. Existing handlers
that ignore the args are unaffected.
PEW is a no-arg event, so the engine broadcasts it through the
`FUN_FIRE_EVENT_NO_ARGS` dispatcher; we intercept that (via the shared
`Event::SignalHook`) and re-broadcast through the variadic dispatcher with
the payload. The flags are derived from whether the in-game Lua state was
just (re)built (login/`reload`) and whether we just came through the
glue/login screen — no `PLAYER_ENTERING_WORLD`-specific engine hook.
### `PLAYER_TOTEM_UPDATE` event
Backports the TBC event (absent from vanilla's table, so reserved as a
custom event). Fires with `arg1 = totemSlot` (1 Fire, 2 Earth, 3 Water,
4 Air) whenever a totem is dropped, expires, or is destroyed early
(killed / Totemic Recall) — the companion event to
[`GetTotemInfo`](#gettoteminfoslot).
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_TOTEM_UPDATE")
f:SetScript("OnEvent", function()
local slot = arg1
local have, name, start, duration, icon = GetTotemInfo(slot)
-- re-read the changed slot
end)
```
Driven by the same [`GetTotemInfo`](#gettoteminfoslot) tracker: the drop
is detected from `SMSG_SPELL_GO`, removal from the WorldTick object-manager
scan, and the event is flushed from the tick (so handlers run in a safe
context, not mid-packet). Removal detection carries up to the tracker's
scan interval (~250 ms) of latency; drops fire on the next frame.
### `BAG_UPDATE_DELAYED` event
Fires (with no payload) once per frame in which any `BAG_UPDATE`
@@ -3545,6 +3659,26 @@ end)
C_GameObjectInfo.RequestLoadGameObjectByID(7000032)
```
### `ClosestGameObjectPosition(gameObjectID)`
Returns `xPos, yPos, distance` — the world position of the nearest game
object with the given GO-template ID, and its distance from the player in
yards. Returns nothing (nil) when no matching object is in range.
```lua
local x, y, dist = ClosestGameObjectPosition(1617) -- nearest Herb Bush, etc.
if x then
-- x, y = world coords; dist = yards from the player
end
```
The game-object counterpart of
[`ClosestUnitPosition`](#closestunitpositioncreatureid) — same visible-
object-manager scan, filtered to game objects (GUID prefix `0xF110`).
Same retail-vs-vanilla caveat: this finds the nearest **currently-visible**
object of that entry rather than reading retail's static starting-zone
database (which vanilla ships no equivalent of).
## GameTooltip
### `GameTooltip:SetItemByID(itemID)`
@@ -4256,6 +4390,53 @@ post-vanilla and included for parity — vanilla items never report them.
if C_Item.GetItemInventoryType(loc) == Enum.InventoryType.IndexHeadType then ...
```
### `Enum.ItemClass`
The item-class enum — the numeric `classID` reported as the 12th return
of [`GetItemInfo`](#c_itemgetiteminfoiteminfo) and taken by
[`GetItemClassInfo`](#getitemclassinfoclassid) /
[`GetItemSubClassInfo`](#getitemsubclassinfoclassid-subclassid). The
numeric values match retail; the obsolete slots keep their modern key
names even though `ItemClass.dbc` labels them `"…(OBSOLETE)"`.
| Value | Field | Value | Field |
|------:|-------|------:|-------|
| 0 | `Consumable` | 8 | `ItemEnhancement` |
| 1 | `Container` | 9 | `Recipe` |
| 2 | `Weapon` | 10 | `CurrencyTokenObsolete` |
| 3 | `Gem` | 11 | `Quiver` |
| 4 | `Armor` | 12 | `Questitem` |
| 5 | `Reagent` | 13 | `Key` |
| 6 | `Projectile` | 14 | `PermanentObsolete` |
| 7 | `Tradegoods` | 15 | `Miscellaneous` |
Values `16..19` (`Glyph`, `Battlepet`, `WoWToken`, `Profession`) are
post-vanilla and included for parity — vanilla items never report them.
```lua
if select(12, GetItemInfo(id)) == Enum.ItemClass.Weapon then ...
```
### `Enum.ItemQuality`
The item-quality enum — the numeric `quality` reported by
[`GetItemInfo`](#c_itemgetiteminfoiteminfo) /
[`C_Item.GetItemQuality`](#c_itemgetitemqualityitemlocation--c_itemgetitemqualitybyiditem).
| Value | Field | Value | Field |
|------:|-------|------:|-------|
| 0 | `Poor` | 4 | `Epic` |
| 1 | `Common` | 5 | `Legendary` |
| 2 | `Uncommon` | 6 | `Artifact` |
| 3 | `Rare` | | |
The post-vanilla `Heirloom` (7) and `WoWToken` (8) tiers are omitted —
no such items exist on 1.12.
```lua
if select(3, GetItemInfo(id)) == Enum.ItemQuality.Epic then ...
```
### `Enum.PowerType`
The integer enum `UnitPowerType` returns and `UnitPower` /
@@ -4903,15 +5084,37 @@ C_Item.GetItemFamily(2447) -- 32 (Peacebloom → herb bag-family)
C_Item.GetItemFamily(6948) -- 0 (Hearthstone → general-purpose)
```
Bitmask values match modern WoW's encoding (`1 << (familyID - 1)`):
Bitmask values follow the vanilla `ItemBagFamily.dbc` IDs converted to
the modern bitmask form (`1 << (familyID - 1)`). The IDs 1–9 match
retail exactly; **10–13 are Turtle WoW custom families** (retail vanilla
1.12 stops at Keys):
| Bit | Value | Family |
|-----|-------|--------|
| 0 | 1 | Quiver (arrows) |
| 1 | 2 | Ammo Pouch (bullets) |
| 2 | 4 | Soul Shard Bag |
| 5 | 32 | Herb Bag |
| 6 | 64 | Enchanting Bag (modern) |
| Bit | Value | Raw ID | Family | Notes |
|-----|--------|--------|-----------------------|-------|
| 0 | 1 | 1 | Quiver (arrows) | |
| 1 | 2 | 2 | Ammo Pouch (bullets) | |
| 2 | 4 | 3 | Soul Bag | |
| 5 | 32 | 6 | Herb Bag | |
| 6 | 64 | 7 | Enchanting Bag | |
| 7 | 128 | 8 | Engineering Bag | |
| 8 | 256 | 9 | Keyring | |
| 9 | 512 | 10 | **Meat Bag** (Turtle) | ⚠ collides with retail's Gem Bag (`0x200`) |
| 10 | 1024 | 11 | **Fish Bag** (Turtle) | ⚠ collides with retail's Mining Bag (`0x400`) |
| 11 | 2048 | 12 | **Leather Bag** (Turtle) | no retail equivalent bit |
| 12 | 4096 | 13 | **Mining Bag** (Turtle) | ⚠ not retail's Mining Bag bit (`0x400`) |
> **Turtle custom families & the retail collision.** Turtle added four
> cooking/gathering bag families (Meat, Fish, Leather, Mining) as
> `ItemBagFamily.dbc` rows 10–13, rather than reusing retail's later
> IDs 4 (Leatherworking) / 5 (Inscription), which vanilla left unused.
> Because we convert the raw ID straight through `1 << (rawID - 1)`,
> these land on bits 9–12 — the same numeric values retail assigned to
> **Gem Bag** (`0x200`) and **Mining Bag** (`0x400`). So an addon
> backported from retail that hard-codes `family == 0x200` to mean "Gem
> Bag" will misread a Turtle Meat Bag. The values are self-consistent
> *within* Turtle (a Meat Bag and the meat items it holds both report
> `0x200`, which is all the "does this item fit this bag" test needs) —
> just don't assume retail's bit meanings for anything above `0x100`.
> **Encoding deviation under the hood.** 1.12 actually stores the raw
> 1-based BagFamily ID (`arrow=1, bullet=2, soul shard=3, herb=6, …`).
@@ -4920,16 +5123,20 @@ Bitmask values match modern WoW's encoding (`1 << (familyID - 1)`):
> callers backporting from modern see the encoding they expect — addons
> can `band(family, FAMILY_BAG_HERB_BAG)` directly.
> **Item-level data is reliable; bag-level is sparse.** Individual
> loot items (arrows, bullets, shards, herbs) carry the field
> reliably, so `C_Item.GetItemFamily` works for the auto-routing use
> case. **Bag containers** themselves (Soul Pouch, Enchanting Bag,
> Herb Pouch, etc.) leave the field at 0 in vanilla server data — at
> least on Turtle WoW. If you need to know "is this bag specialty",
> read the bag's `subClass` via
> [`C_Item.GetItemInfoInstant`](#c_itemgetiteminfoinstantitem)
> instead. The Quiver class is fully populated; everything else needs
> the subClass fallback.
> **Legacy Blizzard bags derive from `(class, subClass)`.** The stock
> vanilla bags shipped with an empty `m_bagFamily` field — the four
> profession bags (Soul Bag, Herb Bag, Enchanting Bag, Engineering Bag,
> all Container class) *and* quivers/ammo pouches (Quiver class). For a
> bag whose field is `0` we recover the family from its class + subclass
> — the same signal the `"24 Slot Soul Bag"` tooltip line is built from.
> A Felcloth Bag reports `0x4` (Soul Shards), a quiver `0x1` (arrows), an
> ammo pouch `0x2` (bullets) — each matching the items it holds. Keyed on
> class because subclass numbers repeat across classes (Container
> subclass 2 = Herb Bag, Quiver subclass 2 = Quiver). This is automatic;
> you no longer need to read `subClass` yourself. (Turtle's own custom
> bags — Meat/Fish/Leather/Mining — populate the field directly; a
> Turtle-specific resolver module supplies their mapping as a safety net,
> kept separate from the stock table.)
> **`nil` vs `0`.** Modern WoW returns `0` for items the cache lookup
> fails on; we return `nil` so callers can distinguish "item not
@@ -5470,13 +5677,31 @@ what the item actually grants — not just its stored stat slots:
bare itemID (a superset of retail, which is link-only). Returns `nil` if
the item isn't cached yet — warm it via `GetItemInfo` and retry.
### `C_Item.GetItemSubClassInfo(classID, subClassID)`
### `GetItemClassInfo(classID)`
Returns `subClassName, subClassUsesInvType` for an item class/subclass pair.
Returns `className` — the localized `ItemClass.dbc` name for an item
class ID (the `classID` from `Enum.ItemClass` / the 12th `GetItemInfo`
return). Also available as `C_Item.GetItemClassInfo(classID)`.
```lua
C_Item.GetItemSubClassInfo(2, 7) -- "One-Handed Swords", false
C_Item.GetItemSubClassInfo(4, 1) -- "Cloth", true
GetItemClassInfo(2) -- "Weapon"
GetItemClassInfo(4) -- "Armor"
GetItemClassInfo(1) -- "Container"
```
Returns `nil` for an unknown class. Obsolete slots return the client's
literal string (`GetItemClassInfo(3)` → `"Jewelry(OBSOLETE)"`), which is
the real vanilla data — use [`Enum.ItemClass`](#enumitemclass) for the
modern key names.
### `GetItemSubClassInfo(classID, subClassID)` / `C_Item.GetItemSubClassInfo(classID, subClassID)`
Returns `subClassName, subClassUsesInvType` for an item class/subclass pair
(the global form documents the second value as `isArmorType` — same value).
```lua
GetItemSubClassInfo(2, 7) -- "One-Handed Swords", false
GetItemSubClassInfo(4, 1) -- "Cloth", true
```
- `subClassName` (string) — the localized `ItemSubClass.dbc` name (verbose
@@ -6839,6 +7064,83 @@ for word in string.gmatch("a,bb,ccc", "[^,]+") do print(word) end -- a / bb / cc
> a hot VM-core function. This is the long-standing vanilla 1.12 constraint —
> always write `string.match(s, p)`, never `s:match(p)`.
### `strsplit(sep, str [, pieces])`
The WoW global (backported from 3.3.5), splits `str` on **any** character in
`sep` and returns the pieces as multiple values.
- `sep` — a set of delimiter *characters* (not a pattern); each character is
a delimiter. `","` splits on commas; `" -"` splits on spaces and dashes.
- `pieces` (optional) — caps the number of results. After `pieces - 1`
splits the rest of the string (delimiters and all) is returned as the final
piece. `0` or omitted = unlimited. `1` returns the whole string unsplit.
Consecutive/trailing delimiters produce empty pieces, matching retail.
```lua
strsplit(",", "a,b,c") -- "a", "b", "c"
strsplit(",", "a,b,c,d", 2) -- "a", "b,c,d" (capped at 2)
strsplit(" -", "a b-c") -- "a", "b", "c" (space OR dash)
strsplit(",", "a,,b") -- "a", "", "b" (empty middle piece)
local zone, x, y = strsplit(":", "Durotar:52:38")
```
Errors `strsplit(): Stack overflow` if a string splits into more pieces than
the Lua stack can grow to hold (`lua_checkstack` guards every push) — the
same guard and message 3.3.5 uses.
### `strjoin(delimiter, ...)`
The inverse of `strsplit`: concatenate the vararg pieces with `delimiter`
between each.
```lua
strjoin(",", "a", "b", "c") -- "a,b,c"
strjoin("", "a", "b", "c") -- "abc"
strjoin(",") -- "" (no pieces)
```
A `nil` / non-string-coercible piece is treated as an empty string (so the
delimiters still line up) rather than erroring.
### `strtrim(str [, chars])`
Remove any of the characters in `chars` from both ends of `str`. `chars` is a
literal **set of characters** (not a Lua pattern); it defaults to whitespace
(space, tab, `\n`, `\v`, `\f`, `\r`).
```lua
strtrim(" hello ") -- "hello"
strtrim("xxhelloxx", "x") -- "hello"
strtrim("[a]", "[]") -- "a"
```
### `strreplace(str, find, replace)`
Replace every occurrence of the literal substring `find` with `replace`,
returning `(result, count)`. This is a **plain-text** replace — no pattern
magic-character escaping, unlike `string.gsub`. An empty `find` returns `str`
unchanged with count `0`.
```lua
strreplace("a.b.c", ".", "-") -- "a-b-c", 2 (no pattern escaping)
strreplace("hello world", "o", "0") -- "hell0 w0rld", 2
```
> Not a stock WoW global — a ClassicAPI convenience. For pattern-based
> replacement use `string.gsub`.
### `string.reverse(s)` / `strrev(s)`
Returns `s` with its bytes reversed. Lua 5.1 added `string.reverse`; 5.0's
string library lacks it. Byte-wise (not UTF-8 aware), matching stock Lua.
Registered both on the `string` table and as the `strrev` global.
```lua
string.reverse("abc") -- "cba"
strrev("hello") -- "olleh"
```
### `math.fmod(x, y)`
The floating-point remainder of `x / y` (the quotient truncated toward zero),
@@ -6853,6 +7155,28 @@ math.fmod(-7, 3) -- -1 (result takes the sign of the dividend)
math.fmod(5.5, 2) -- 1.5
```
### `math.modf(x)`
Returns `(integral, fractional)` — the integral part truncated toward zero
and the fractional part carrying `x`'s sign. Lua 5.1 function, genuinely
missing from 1.12's math library (not just renamed like `fmod`).
```lua
math.modf(3.7) -- 3, 0.7
math.modf(-3.7) -- -3, -0.7
math.modf(5) -- 5, 0
```
### `math.huge`
Positive infinity, as the numeric constant `math.huge` (Lua 5.1). Handy as a
"larger than anything" sentinel in min/max scans.
```lua
local best = math.huge
for _, v in ipairs(costs) do best = math.min(best, v) end
```
### `coroutine.*`
Restores Lua 5.0's stripped `coroutine.*` library. The C-level
@@ -7721,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
@@ -10644,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
@@ -10937,6 +11365,97 @@ wall-clock time). Returns 0 if called before login.
> midnight, not UTC midnight (though they happen to coincide for
> Turtle WoW since the server reports UTC-aligned components).
## Totem
### `GetTotemInfo(slot)`
Returns `haveTotem, totemName, startTime, duration, icon, modRate, spellID`
for one of the four shaman totem slots (`1` Fire, `2` Earth, `3` Water,
`4` Air).
```lua
local _, name, start, duration, icon = GetTotemInfo(1) -- Fire slot
if name ~= "" then -- a totem IS active
local remaining = duration - (GetTime() - start)
-- name = "Searing Totem", icon = "Interface\\Icons\\...", etc.
end
```
| Return | Meaning |
|--------|---------|
| `haveTotem` | `true` if the player carries the slot's **totem tool** (Fire/Earth/Water/Air Totem item) — the documented meaning, **not** whether a totem is summoned. |
| `totemName` | The active totem spell's localized name (`""` when no totem is out — this is how you test "is a totem active"). |
| `startTime` | `GetTime()` value when the totem was cast (`0` when none). |
| `duration` | Total duration in seconds (`0` when none). |
| `icon` | The active totem spell's icon texture path, or `nil` when none. |
| `modRate` | Always `1` — vanilla has no per-totem haste. |
| `spellID` | The active summon spell's ID (`0` when none). |
> **`haveTotem` is tool presence, not summon state.** Matching the live
> API: `haveTotem` reflects whether you hold the slot's totem **tool** item
> (`Fire Totem` 5176, `Earth Totem` 5175, `Water Totem` 5177, `Air Totem`
> 5178) in bags/equipped, independent of whether a totem is currently
> deployed. The tool item is the totem spell's `Totem[0]` requirement (a
> *tool*, not a consumed reagent), read from the spell — not hardcoded. To
> check for an *active* totem, test `totemName ~= ""` (or `startTime > 0`).
Vanilla 1.12 has no client-side totem tracking (the totem bar,
`PLAYER_TOTEM_UPDATE`, and `GetTotemInfo` are all TBC additions), so this
is self-tracked but **data-driven**: the slot is read from the summon
spell's `Spell.dbc` effect (`SUMMON_TOTEM_SLOT1..4` = Fire/Earth/Water/Air),
duration from `SpellDuration.dbc`, the totem's creature entry (for death
detection) from the effect's `EffectMiscValue`, and the `haveTotem` tool
item from the spell's `Totem` field (the totem the spell requires) —
resolved from the player's spellbook, not a hardcoded table. Casts are observed
via the `SMSG_SPELL_GO` hook; a slot clears when its duration elapses OR
when the totem creature disappears early (killed, Totemic Recall, or
destroyed) — detected by scanning the object manager for the player-owned
totem creature. Because it's data-driven, Turtle-custom totems are tracked
automatically. Slot changes fire
[`PLAYER_TOTEM_UPDATE`](#player_totem_update-event).
### `GetTotemTimeLeft(slot)`
Returns the seconds remaining before the slot's totem is auto-destroyed,
or `0` when no totem is active in that slot. Slots: `1` Fire, `2` Earth,
`3` Water, `4` Air.
```lua
local remaining = GetTotemTimeLeft(1) -- Fire slot, e.g. 12.4
```
Computed from the same tracker as [`GetTotemInfo`](#gettoteminfoslot)
(`startTime + duration − now`), so it shares that data's caveats — most
notably it reflects the totem's *full* duration from cast, unaffected by
any server-side early expiry the client isn't told about (the WorldTick
scan still clears the slot when the totem creature actually disappears).
### `GetTotemDuration(slot)`
Returns the slot's active totem's **total** duration in seconds (its full
lifetime from cast), or `0` when no totem is active. The companion to
[`GetTotemTimeLeft`](#gettotemtimeleftslot) (remaining time) — same value
as `GetTotemInfo`'s 4th return.
```lua
local total = GetTotemDuration(1) -- e.g. 60
local frac = GetTotemTimeLeft(1) / GetTotemDuration(1) -- bar fill
```
### `TargetTotem(slot)`
Targets the player's totem in the given slot (`1` Fire, `2` Earth, `3`
Water, `4` Air). No-op when the slot has no active totem or the totem
creature isn't currently visible in the client.
```lua
TargetTotem(2) -- target your Earth totem
```
Looks up the totem creature's live GUID (object-manager scan for the
player-owned creature of the slot's tracked entry) and sets the target
through the same engine path `TargetUnit` uses (`CMSG_SET_SELECTION`).
## TradeSkillUI
Backports the 2.x+ **trade-skill list link** — the shareable
@@ -11419,6 +11938,20 @@ players (no owner) return `false`; your own minions (owner is you) return
UnitIsOtherPlayersPet("target") -- a party member's pet → true; your own pet → false
```
### `UnitOwnerGUID(unit)`
Returns the GUID string (`"0x…"`) of the unit's owner — the summoner of a
pet/guardian/totem, or the charmer of a charmed unit. `nil` for an
unresolved unit and for anything with no owner (players, world creatures).
```lua
local owner = UnitOwnerGUID("pettarget") -- e.g. the enemy hunter's GUID
if owner == UnitGUID("player") then ... end -- is it my minion?
```
Reads the same owner field the pet predicates use (`CharmedBy`, else
`CreatedBy` in the unit's `m_objectFields`).
### `UnitStandState(unit)`
Returns the unit's standstate as an integer, matching the modern
@@ -11467,13 +12000,25 @@ end
| Return | Meaning |
|--------|---------|
| `inRange` | `true` if the unit's world position is within 40 yards of the player's. `false` if out of range OR if `checkedRange` is `false`. |
| `inRange` | `true` if the unit is within a 40-yard heal's reach of the player. `false` if out of range OR if `checkedRange` is `false`. |
| `checkedRange` | `true` when a position-based range check was performed. `false` when the unit's position isn't available — either the token didn't resolve (empty `partyN` slot, no target, raid member in a different zone, etc.) or `unit == "player"` (see below). |
Reads the world position via the `CGObject::GetPosition` vtable
virtual (slot 5, offset `+0x14`) — same path
`CheckInteractDistance` uses. Squared-distance compare against
`40 * 40 = 1600`.
`CheckInteractDistance` uses.
**Reach-aware.** The threshold is not a flat 40 yards on the raw
center-to-center distance — it's `40 + playerReach + targetReach`,
mirroring the engine's own spell-range formula (`FUN_006e3480`), where
each unit's reach is the bounding-radius float at `[m_objectFields +
0x1F0]` (the same size factor the interact-distance and loot-range
checks add). A 40-yard heal lands on a target whose *center* is up to
`40 + reach` yards away, so a bare center-distance cap of 40 wrongly
reports out-of-range for a target the client can actually heal (on a
large target the reach gap is 2–3 yards). If a unit's descriptor isn't
populated yet the reach term is 0 and the check degrades to a plain
40-yard cap. Note this is the one difference from `UnitDistanceSquared`,
which stays raw center-to-center (matching retail).
> **`UnitInRange("player")` returns `(false, false)`** by design,
> matching modern WoW's behavior. The function is meant for healing-
@@ -11617,6 +12162,36 @@ from the DBC). Reads byte 0 of `UNIT_FIELD_BYTES_0` (descriptor
Returns `(nil, nil)` for unresolvable units and non-player units
(creature race bytes don't index `ChrRaces.dbc`).
### `ClosestUnitPosition(creatureID)`
Returns `xPos, yPos, distance` — the world position of the nearest
creature with the given NPC (creature-template) ID, and its distance from
the player in yards. Returns nothing (nil) when no matching creature is in
range.
```lua
local x, y, dist = ClosestUnitPosition(3098) -- nearest Kobold Vermin
if x then
-- x, y = world coords; dist = yards from the player
end
```
- `creatureID` (number) — the NPC ID (the value
[`C_CreatureInfo.GetCreatureID(guid)`](#c_creatureinfogetcreatureidguid)
returns, and the entry packed into a creature's GUID).
Walks the client's visible-object manager, matches creatures whose GUID
encodes `creatureID` (bits 24–47), and returns the closest one's position
+ center-to-center distance.
> **Differs from retail.** Modern WoW's `ClosestUnitPosition` reads a
> static client-side spawn database and only works for starting-zone mobs.
> Vanilla 1.12 ships no such database, so this returns the nearest
> **currently-visible** creature of that entry (anywhere, not just
> starting zones). It can't point at un-synced spawns elsewhere in the
> zone the way retail's database can — but for "where's the nearest `<mob>`
> I can see" it's more general than retail.
### `UnitPower(unit [, powerType])` / `UnitPowerMax(unit [, powerType])`
Modern multi-power-type getters. Vanilla 1.12 only ships
+2 -1
View File
@@ -57,7 +57,8 @@ namespace Lua {
F(Resume, lua_resume, LUA_RESUME) \
F(Yield, lua_yield, LUA_YIELD) \
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
F(SetN, luaL_setn, LUAL_SETN)
F(SetN, luaL_setn, LUAL_SETN) \
F(CheckStack, lua_checkstack, LUA_CHECK_STACK)
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
+2
View File
@@ -100,6 +100,7 @@ using lua_resume_t = int(__fastcall *)(void *L, int nargs);
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
using luaL_setn_t = void(__fastcall *)(void *L, int t, int n);
using lua_checkstack_t = int(__fastcall *)(void *L, int size);
extern const lua_isnumber_t IsNumber;
extern const lua_isstring_t IsString;
@@ -142,6 +143,7 @@ extern const lua_resume_t Resume;
extern const lua_yield_t Yield;
extern const luaL_argerror_t ArgError;
extern const luaL_setn_t SetN;
extern const lua_checkstack_t CheckStack;
// Returns the global `lua_State *` (read on demand from the engine's global).
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
+50
View File
@@ -454,6 +454,39 @@ enum Offsets {
// pointer the inventory routines expect.
FUN_RESOLVE_UNIT_TOKEN = 0x00515940,
// Set the player's current target to a GUID —
// `__fastcall(uint64_t *guid)`. Validates the GUID resolves to a unit
// (typemask 8) then commits the selection (CMSG_SET_SELECTION +
// client-side target). This is what `Script_TargetUnit` (0x004899D0)
// calls after resolving its token→GUID; we call it directly with a
// GUID we already have (e.g. a totem creature). NOTE: reads `guid[0]`
// (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
@@ -1467,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
@@ -3796,6 +3838,14 @@ enum Offsets {
// `rawset(nil)` clear leaves it stale and subsequent `table.insert`
// appends past the wiped slots.
LUAL_SETN = 0x6F4EA0,
// `lua_checkstack(L, n)` — `int __fastcall(L /*ecx*/, n /*edx*/)`.
// Ensures room for `n` more stack values, growing if needed; returns 0
// (without growing) when `(top-base)/16 + n` would exceed LUA_MAXCSTACK
// (0x800 = 2048), 1 otherwise. Verified by the `cmp reg, 0x800` overflow
// test at 0x006F2F43. Needed by any C function that pushes an unbounded
// number of results (e.g. `strsplit`) — pushing past the ~20-slot
// C-call guarantee without growing corrupts the stack.
LUA_CHECK_STACK = 0x006F2F30,
// `str_find` — the Lua 5.0 string-library `string.find` C function
// (`int __fastcall(void *L)`), entry in the strlib luaL_reg table at
// `0x00822dd0`. Returns `1` (nil pushed, no match), `2` (start, end),
+21 -28
View File
@@ -132,30 +132,19 @@ const Override *FindOverride(uint32_t spellId) {
return nullptr;
}
// Known values for spells whose duration row DANGLES — a non-zero
// DurationIndex pointing at a SpellDuration row the client doesn't have.
// Consulted ONLY when that exact condition holds, so the gate is the bug
// itself, not a server fingerprint: on a stock client every one of these
// resolves to a valid row and this table is never reached, and if the
// broken client data is ever fixed the DBC row takes over automatically.
//
// Turtle's reworked Rip is the known case: all six ranks point at
// SpellDuration row 87, which Turtle added on the SERVER only
// ({8000, 0, 18000} — live server data shows 10/12/14/16/18s at 1..5 CP,
// i.e. 8s + 2s per combo point); their client patch never shipped the
// row (the client table jumps 86 -> 105). Verified the only dangling
// duration index in the entire client Spell.dbc.
constexpr Override kDanglingRowKnown[] = {
{1079, 8000, 18000}, {9492, 8000, 18000}, {9493, 8000, 18000},
{9752, 8000, 18000}, {9894, 8000, 18000}, {9896, 8000, 18000},
};
// Resolver chain for dangling-duration-row spells (client DBC missing the
// row). The KNOWN VALUES themselves are server-custom data and live in
// their own module (e.g. `src/turtle/ComboDuration.cpp` for Turtle's
// reworked Rip); this stock module only owns the mechanism and the
// dangling-condition gate. Chained at static-init by `AutoDanglingResolver`.
AutoDanglingResolver *g_danglingResolvers = nullptr;
const Override *FindDanglingRowKnown(uint32_t spellId) {
for (const auto &k : kDanglingRowKnown) {
if (k.spellId == spellId)
return &k;
}
return nullptr;
bool ResolveDangling(uint32_t spellId, int32_t *baseMs, int32_t *maxMs) {
for (AutoDanglingResolver *r = g_danglingResolvers; r != nullptr;
r = r->next)
if (r->fn(spellId, baseMs, maxMs))
return true;
return false;
}
// `C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)`
@@ -203,6 +192,11 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
AutoDanglingResolver::AutoDanglingResolver(DanglingResolver f)
: fn(f), next(g_danglingResolvers) {
g_danglingResolvers = this;
}
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
if (spellRecord == nullptr || spellId == 0)
return 0;
@@ -227,12 +221,11 @@ uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
maxMs = *reinterpret_cast<const int32_t *>(row + 0xC);
} else if (idx != 0) {
// Dangling duration index — the client data is broken for this
// spell. Fall back to the built-in known values (Turtle Rip).
const Override *k = FindDanglingRowKnown(spellId);
if (k == nullptr)
// spell. Defer to a registered resolver (the known values are
// server-custom data owned by a platform module, e.g.
// src/turtle for Turtle's reworked Rip).
if (!ResolveDangling(spellId, &baseMs, &maxMs))
return 0;
baseMs = k->baseMs;
maxMs = k->maxMs;
} else {
return 0; // no duration row at all
}
+18
View File
@@ -47,4 +47,22 @@ namespace Aura::ComboDuration {
// duration path".
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId);
// Extension point for finishers whose SpellDuration row DANGLES — a
// non-zero DurationIndex pointing at a row the client DBC doesn't have.
// A resolver returns true and fills *baseMs/*maxMs if it knows the spell,
// false otherwise. Consulted by `TryComboScaledMs` ONLY in that dangling
// condition (after any Lua-registered override), so the activation gate is
// the client-data bug itself, not a server fingerprint — the deliberate
// design choice (a realm-sniffing approach was rejected). Server-custom
// data (e.g. Turtle's reworked Rip) registers its values here instead of
// living in this stock module: declare a file-scope
// `static const Aura::ComboDuration::AutoDanglingResolver`.
using DanglingResolver = bool (*)(uint32_t spellId, int32_t *baseMs,
int32_t *maxMs);
struct AutoDanglingResolver {
explicit AutoDanglingResolver(DanglingResolver fn);
DanglingResolver fn;
AutoDanglingResolver *next;
};
} // namespace Aura::ComboDuration
+6
View File
@@ -23,6 +23,7 @@
#include "player/StatSignal.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "totem/Tracker.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -230,6 +231,11 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
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())
Totem::Tracker::OnPlayerSpellGo(spellId);
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
if (!SpellAppliesAura(rec))
return;
+40 -5
View File
@@ -14,16 +14,21 @@
// Lua 5.1 math-library additions that 1.12's Lua 5.0 is missing:
//
// - `math.fmod(x, y)` — floating-point remainder of `x / y`.
// - `math.modf(x)` — split into integral + fractional parts.
// - `math.huge` — positive infinity constant.
//
// This is a pure rename: Lua 5.0 ships the exact same C function under the
// name `math.mod`; 5.1 renamed the Lua-facing entry to `math.fmod`. We
// register `math.fmod` as a direct alias of the engine's `math.mod` C
// function (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too
// (we don't touch it), so code written for either name works.
// `math.fmod` is a pure rename: Lua 5.0 ships the exact same C function under
// the name `math.mod`; 5.1 renamed the Lua-facing entry to `math.fmod`. We
// register it as a direct alias of the engine's `math.mod` C function
// (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too, so code
// written for either name works. `modf` and `huge` are genuinely new in 5.1
// (not in 1.12's mathlib table), so we implement/define them here.
#include "Game.h"
#include "Offsets.h"
#include <cmath>
namespace BaseLib::MathLib {
namespace {
@@ -33,8 +38,38 @@ namespace {
const auto Script_math_fmod =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_MATH_FMOD);
// `math.modf(x)` — returns `(integral, fractional)`, the integral part
// truncated toward zero and the fractional part carrying `x`'s sign
// (`modf(3.7) → 3, 0.7`; `modf(-3.7) → -3, -0.7`). Matches C `modf` / Lua 5.1.
int __fastcall Script_math_modf(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: math.modf(x)");
return 0; // unreachable
}
double integral = 0.0;
const double fractional = std::modf(Game::Lua::ToNumber(L, 1), &integral);
Game::Lua::PushNumber(L, integral);
Game::Lua::PushNumber(L, fractional);
return 2;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("math", "fmod", Script_math_fmod);
Game::Lua::RegisterTableFunction("math", "modf", &Script_math_modf);
// `math.huge` — a numeric constant, not a function. Fetch the (now
// guaranteed-present) `math` table and set the field directly.
void *L = Game::Lua::State();
if (L != nullptr) {
Game::Lua::PushString(L, "math");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [math]
if (Game::Lua::Type(L, -1) == 5 /* LUA_TTABLE */) {
Game::Lua::SetFieldNumber(L, "huge", HUGE_VAL); // math.huge = inf; [math]
Game::Lua::SetTop(L, -2); // pop [math]
} else {
Game::Lua::SetTop(L, -2); // pop whatever GetTable returned
}
}
}
// Both states are Lua 5.0 and equally missing this; RegisterTableFunction
+188 -4
View File
@@ -11,16 +11,22 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Lua 5.1 string-library additions that 1.12's Lua 5.0 is missing:
// String helpers that 1.12's Lua 5.0 is missing:
//
// - `string.match(s, pattern [, init])` — first-match extraction.
// - `string.gmatch(s, pattern)` — match iterator.
// - `string.match(s, pattern [, init])` — first-match extraction (5.1).
// - `string.gmatch(s, pattern)` — match iterator (5.1).
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
// - `strjoin(delimiter, ...)` — WoW global; join varargs.
// - `strtrim(str [, chars])` — WoW global; trim a char set.
// - `strreplace(str, find, replace)` — plain-text replace (see below).
// - `string.reverse(s)` / `strrev(s)` — 5.1 string function, missing in 5.0.
//
// Both are the same underlying pattern engine as functions 5.0 already ships:
// The two pattern `string.*` ones reuse machinery 5.0 already ships:
// - `match` is `find` returning captures / the whole match instead of the
// start/end indices — so we call the engine's `str_find` and transform.
// - `gmatch` is exactly 5.0's `string.gfind` (renamed in 5.1); we register
// it as a direct alias of the engine's `gfind` C function.
// `strsplit` is a hand-rolled port of 3.3.5's `strsplit` (see below).
//
// NOTE: only the `string.foo(s, ...)` call form works — NOT the
// `("x"):foo(...)` method sugar. WoW's Lua VM strips type-metatables for
@@ -33,6 +39,8 @@
#include "Offsets.h"
#include <cstddef>
#include <cstring>
#include <string>
namespace BaseLib::StringLib {
@@ -83,9 +91,185 @@ int __fastcall Script_string_match(void *L) {
const auto Script_string_gmatch =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GFIND);
// `strsplit(sep, str [, pieces])` — the WoW global (also aliased as
// `string.split`), split `str` on ANY character in `sep` and return the
// pieces as multiple values. `pieces > 0` caps the result count, with the
// unsplit remainder as the final piece; `0` / omitted = unlimited. Ported
// from 3.3.5's `strsplit` (FUN_00816a60) with one deliberate change: we do
// NOT `lua_settop(L, 0)` up front. Popping the args would leave the source
// string unreferenced, so a GC step triggered by a `pushlstring` could free
// the very buffer we're still scanning. Keeping the args on the stack keeps
// the source GC-rooted; Lua returns the top N values (our pieces) regardless
// of the args sitting below them. Each push is guarded by `lua_checkstack`
// (this pushes an unbounded number of results), erroring like 3.3.5 on
// genuine stack exhaustion.
int __fastcall Script_strsplit(void *L) {
const char *sep = Game::Lua::ToString(L, 1);
const char *str = Game::Lua::ToString(L, 2);
if (sep == nullptr || str == nullptr) {
Game::Lua::Error(L, "Usage: strsplit(\"separators\", str [, pieces])");
return 0; // unreachable
}
const int pieces =
Game::Lua::IsNumber(L, 3) ? static_cast<int>(Game::Lua::ToNumber(L, 3)) : 0;
const char *segStart = str;
int count = 0;
// Only scan for separators while unlimited (pieces == 0) or the cap
// hasn't been reached (pieces > 1). pieces == 1 (or <= 0 but non-zero)
// yields the whole string as a single piece — matches 3.3.5.
if (pieces == 0 || pieces > 1) {
for (const char *p = str; *p != '\0'; ++p) {
bool isSep = false;
for (const char *s = sep; *s != '\0'; ++s) {
if (*p == *s) {
isSep = true;
break;
}
}
if (!isSep)
continue;
++count;
if (Game::Lua::CheckStack(L, count) == 0) {
Game::Lua::Error(L, "strsplit(): Stack overflow");
return 0; // unreachable
}
Game::Lua::PushLString(L, segStart,
static_cast<unsigned int>(p - segStart));
segStart = p + 1;
if (count == pieces - 1)
break; // cap hit; remainder becomes the final piece
}
}
if (Game::Lua::CheckStack(L, count + 1) == 0) {
Game::Lua::Error(L, "strsplit(): Stack overflow");
return 0; // unreachable
}
Game::Lua::PushString(L, segStart); // final piece (remainder to end)
return count + 1;
}
// `strjoin(delimiter, ...)` — the inverse of `strsplit`: concatenate the
// vararg pieces with `delimiter` between each. Standard WoW global (FrameXML
// defines it in Lua; there's no C original to mirror). A nil / non-coercible
// piece is treated as an empty string rather than erroring, so the delimiter
// placement stays predictable. `strjoin(",")` with no pieces returns "".
int __fastcall Script_strjoin(void *L) {
const char *delim = Game::Lua::ToString(L, 1);
if (delim == nullptr) {
Game::Lua::Error(L, "Usage: strjoin(delimiter, ...)");
return 0; // unreachable
}
const unsigned delimLen = Game::Lua::StrLen(L, 1);
const int top = Game::Lua::GetTop(L);
std::string out; // no Error() past this point — a longjmp would leak it
for (int i = 2; i <= top; ++i) {
if (i > 2)
out.append(delim, delimLen);
const char *piece = Game::Lua::ToString(L, i);
if (piece != nullptr)
out.append(piece, Game::Lua::StrLen(L, i));
}
Game::Lua::PushLString(L, out.data(), static_cast<unsigned int>(out.size()));
return 1;
}
// `strtrim(str [, chars])` — remove any of the characters in `chars` from
// both ends of `str` (default: whitespace). `chars` is a literal set of
// characters, not a pattern. Standard WoW global. Two-ended scan — no
// allocation, embedded NULs preserved (bounds come from the Lua length).
int __fastcall Script_strtrim(void *L) {
const char *str = Game::Lua::ToString(L, 1);
if (str == nullptr) {
Game::Lua::Error(L, "Usage: strtrim(str [, chars])");
return 0; // unreachable
}
const unsigned len = Game::Lua::StrLen(L, 1);
const char *chars = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2)
: " \t\n\v\f\r";
bool trim[256] = {false};
for (const char *c = chars; *c != '\0'; ++c)
trim[static_cast<unsigned char>(*c)] = true;
unsigned start = 0, end = len;
while (start < end && trim[static_cast<unsigned char>(str[start])])
++start;
while (end > start && trim[static_cast<unsigned char>(str[end - 1])])
--end;
Game::Lua::PushLString(L, str + start, end - start);
return 1;
}
// `strreplace(str, find, replace)` — replace every occurrence of the literal
// substring `find` with `replace`; returns `(result, count)`. NOT a stock WoW
// global and NOT pattern-based (that's `gsub`) — a plain-text replace that
// needs no magic-character escaping. Empty `find` returns `str` unchanged
// (count 0) rather than looping forever.
int __fastcall Script_strreplace(void *L) {
const char *str = Game::Lua::ToString(L, 1);
const char *find = Game::Lua::ToString(L, 2);
const char *repl = Game::Lua::ToString(L, 3);
if (str == nullptr || find == nullptr || repl == nullptr) {
Game::Lua::Error(L, "Usage: strreplace(str, find, replace)");
return 0; // unreachable
}
const unsigned strLen = Game::Lua::StrLen(L, 1);
const unsigned findLen = Game::Lua::StrLen(L, 2);
const unsigned replLen = Game::Lua::StrLen(L, 3);
if (findLen == 0) { // nothing to match — return input unchanged
Game::Lua::PushLString(L, str, strLen);
Game::Lua::PushNumber(L, 0.0);
return 2;
}
std::string out; // no Error() past this point
int count = 0;
unsigned i = 0;
while (i < strLen) {
if (i + findLen <= strLen && std::memcmp(str + i, find, findLen) == 0) {
out.append(repl, replLen);
i += findLen;
++count;
} else {
out.push_back(str[i]);
++i;
}
}
Game::Lua::PushLString(L, out.data(), static_cast<unsigned int>(out.size()));
Game::Lua::PushNumber(L, static_cast<double>(count));
return 2;
}
// `string.reverse(s)` / `strrev(s)` — return `s` with its bytes reversed.
// Lua 5.1 added `string.reverse`; 5.0's strlib lacks it. Byte-wise (not
// UTF-8 aware), matching stock Lua; embedded NULs preserved (bounds from the
// Lua length).
int __fastcall Script_string_reverse(void *L) {
const char *s = Game::Lua::ToString(L, 1);
if (s == nullptr) {
Game::Lua::Error(L, "Usage: string.reverse(s)");
return 0; // unreachable
}
const unsigned len = Game::Lua::StrLen(L, 1);
std::string out(len, '\0'); // no Error() past this point
for (unsigned i = 0; i < len; ++i)
out[i] = s[len - 1 - i];
Game::Lua::PushLString(L, out.data(), len);
return 1;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterGlobalFunction("strsplit", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strjoin", &Script_strjoin);
Game::Lua::RegisterGlobalFunction("strtrim", &Script_strtrim);
Game::Lua::RegisterGlobalFunction("strreplace", &Script_strreplace);
Game::Lua::RegisterGlobalFunction("strrev", &Script_string_reverse);
}
// Both states are Lua 5.0 and equally missing these; RegisterTableFunction
+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
+21 -22
View File
@@ -13,6 +13,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/BagFamily.h"
#include "item/ID.h"
#include "item/Location.h"
@@ -73,15 +74,14 @@ BagInfo ResolveBagInfo(int bagID) {
const int slots = static_cast<int>(*reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS));
// Convert 1.12's raw BagFamily ID to the modern bitmask. See
// `Offsets::OFF_ITEMSTATS_BAG_FAMILY` for the encoding-shift story
// and `Item::Info::BagFamilyIdToBitmask` for the canonical helper —
// we inline the one-liner here to avoid a header dance for a
// single-instruction conversion.
const uint32_t rawFamily = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
// BagFamily bitmask: the item's field if populated, else derived from
// the container subclass — Turtle leaves bags' m_bagFamily at 0, so a
// Soul Bag would otherwise report family 0 and get miscounted as a
// general-purpose bag by CalculateTotalNumberOfFreeBagSlots. Shared
// with C_Item.GetItemFamily so a bag and its contents agree. See
// `Item::BagFamily`.
const int family =
(rawFamily == 0) ? 0 : static_cast<int>(1u << (rawFamily - 1));
static_cast<int>(Item::BagFamily::BitmaskForRecord(record));
return {slots, family};
}
@@ -106,14 +106,13 @@ int CountFreeSlotsInBag(void *L, int bagID, int slotCount) {
// `(0, 0)` — matching 3.3.5's behavior of always pushing two values
// rather than nil/nothing.
//
// `bagType` is the `BagFamily` bitmask in modern encoding (raw 1.12
// ID converted via `1 << (ID-1)`). Empirically on Turtle WoW, only
// Light Quiver actually carries a non-zero family for bags themselves
// — Soul Pouch, Enchanting Bag, etc. all return 0 because the
// vanilla-era server data doesn't populate the field. **Individual
// items** (arrows, bullets, shards, herbs) reliably return their
// family, so addons that auto-route loot work correctly via
// `C_Item.GetItemFamily(itemID)` even though `bagType` is sparse.
// `bagType` is the `BagFamily` bitmask in modern encoding. Bags leave the
// raw family field empty in Turtle's data (Soul/Herb/quiver/ammo pouch/…
// all read 0), so it's derived from the bag's class + subclass via
// `Item::BagFamily`: a Soul Bag reports `0x4`, a quiver `0x1`, an ammo
// pouch `0x2` — matching what `C_Item.GetItemFamily` reports for both the
// bag and the items it holds. See that helper for the Turtle custom-family
// caveat (Meat/Fish/Leather/Mining, IDs 10–13).
int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
@@ -139,12 +138,12 @@ int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
// free, family = C_Container.GetContainerNumFreeSlots(bag)
// if family == 0 then total = total + free end
//
// Takes no arguments. Caveat inherited from `GetContainerNumFreeSlots`:
// vanilla-era server data leaves most bags' BagFamily at 0, so specialty
// bags that retail *would* exclude (Soul Pouch, Enchanting Bag, …) get
// counted here — the client just doesn't carry the family bit for them. The
// result stays consistent with what `GetContainerNumFreeSlots` reports for
// those same bags.
// Takes no arguments. Specialty bags are correctly excluded: their family
// comes from `Item::BagFamily` (container-subclass fallback), so a Soul
// Bag / Herb Bag / Meat Bag reports a non-zero family and its slots are
// left out of the general-purpose total — matching retail's FrameXML
// behavior. (An earlier version miscounted them because Turtle leaves the
// raw family field at 0 for bags.)
int __fastcall Script_C_Container_CalculateTotalNumberOfFreeBagSlots(void *L) {
int total = 0;
for (int bagID = 0; bagID <= 4; bagID++) {
+8
View File
@@ -107,11 +107,19 @@ static int AuxResume(void *L, void *co, int nargs) {
: "cannot resume non-suspended coroutine");
return -1;
}
if (Game::Lua::CheckStack(co, nargs) == 0) {
Game::Lua::PushString(L, "too many arguments to resume coroutine");
return -1;
}
Game::Lua::XMove(L, co, nargs);
const int s = Game::Lua::Resume(co, nargs);
const bool yielded = LuaCI(co) > LuaBaseCI(co);
if (s == LUA_OK || yielded) {
const int nres = Game::Lua::GetTop(co);
if (Game::Lua::CheckStack(L, nres) == 0) {
Game::Lua::PushString(L, "too many results to resume coroutine");
return -1;
}
Game::Lua::XMove(co, L, nres);
return nres;
}
+109
View File
@@ -0,0 +1,109 @@
// 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/>.
// `PLAYER_ENTERING_WORLD(isInitialLogin, isReloadingUi)` — the 8.0.1 payload,
// backported. Vanilla fires PEW with no args; backported addons expect the
// two booleans to distinguish a fresh login from a `/reload` from an
// instance/zone transition.
//
// Delivery: PEW is a no-arg event, so the engine broadcasts it through
// `FUN_FIRE_EVENT_NO_ARGS` (the `__fastcall(int)` dispatcher), NOT the
// variadic `FUN_FIRE_EVENT`. We intercept that dispatcher (via the shared
// `Event::SignalHook`) and, for the PEW event id, suppress the no-arg fire
// and instead re-broadcast through the variadic `FUN_FIRE_EVENT` with a
// two-boolean payload. Both fire paths read the same frame chain off the
// event's table slot, so the frames registered for PEW receive the args
// exactly once. Booleans use the `%d`/`%s`+nullptr trick (`1` / `nil`), so
// `if isInitialLogin then …` reads naturally.
//
// Flags, from signals we already produce — no PEW-specific engine hook:
// - isInitialLogin: our GlueModuleAutoRegister runs on every glue-screen
// boot (initial launch + each logout return), so a set latch means "the
// next enter-world is a fresh login". The first PEW consumes it.
// - isReloadingUi: our ModuleAutoRegister runs whenever the in-game Lua
// state is (re)built — login OR `/reload`, but NOT a zone transition. So
// "script re-init happened and it wasn't the fresh login" == a reload.
// - Instance/zone transition rebuilds neither, so both latches are clear →
// both false, matching retail.
#include "Game.h"
#include "event/Custom.h"
#include "event/SignalHook.h"
namespace Event::EnteringWorld {
namespace {
// Set on glue-screen boot; a pending latch means the next enter-world is a
// fresh character login (from the login/char-select screen).
bool g_atGlue = false;
// Set when the in-game Lua state is (re)built (login or /reload).
bool g_scriptReloaded = false;
// Cached PLAYER_ENTERING_WORLD event-table slot; -1 = unresolved. Reset per
// in-game init (the table is rebuilt, though engine slots are stable) and
// re-resolved lazily at fire time.
int g_pewId = -1;
int PewId() {
if (g_pewId < 0)
g_pewId = Event::Custom::LookupByName("PLAYER_ENTERING_WORLD");
return g_pewId;
}
// Shared-hook interceptor. For PEW, re-broadcast with the payload and
// suppress the engine's no-arg fire; everything else passes through.
bool Intercept(int eventID) {
const int pew = PewId();
if (pew < 0 || eventID != pew)
return false;
const bool isInitialLogin = g_atGlue;
g_atGlue = false;
const bool isReloadingUi = !isInitialLogin && g_scriptReloaded;
g_scriptReloaded = false;
// Re-fire through the variadic dispatcher with the two booleans. true → 1
// (%d), false → nil (%s + nullptr, which the dispatcher's pushstring
// tail-jumps to pushnil).
auto *const kNil = static_cast<const char *>(nullptr);
if (isInitialLogin && isReloadingUi)
Event::Custom::Fire(pew, "%d%d", 1, 1);
else if (isInitialLogin)
Event::Custom::Fire(pew, "%d%s", 1, kNil);
else if (isReloadingUi)
Event::Custom::Fire(pew, "%s%d", kNil, 1);
else
Event::Custom::Fire(pew, "%s%s", kNil, kNil);
return true; // handled — suppress the original no-arg PEW fire
}
const Event::SignalHook::AutoSubscribe _intercept{&Intercept};
// In-game Lua (re)init: login or /reload rebuilt the script state (never a
// zone transition). Also re-arm the PEW slot lookup — the event table was
// rebuilt.
void OnInGameInit() {
g_scriptReloaded = true;
g_pewId = -1;
}
// Glue-screen boot: we're at the login/char-select screen, so the next
// enter-world will be a fresh login.
void OnGlueInit() { g_atGlue = true; }
const Game::ModuleAutoRegister _inGame{&OnInGameInit};
const Game::GlueModuleAutoRegister _glue{&OnGlueInit};
} // namespace
} // namespace Event::EnteringWorld
+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
+49
View File
@@ -0,0 +1,49 @@
// 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 "SignalHook.h"
#include "Game.h"
#include "Offsets.h"
namespace Event::SignalHook {
namespace {
// Chained at static-init time by each `AutoSubscribe` constructor, before
// `DllMain`. Zero-initialized (namespace static) ahead of any dynamic init,
// so the head is valid when subscribers link in.
AutoSubscribe *g_head = nullptr;
// `FUN_FIRE_EVENT_NO_ARGS` is `__fastcall(int eventID)`; we mirror loot's
// long-standing shape with a dummy EDX so the trampoline call matches the
// register layout, and forward `0` for it (unused by the callee).
using FireNoArgs_t = void(__fastcall *)(int eventID, int edx);
FireNoArgs_t g_orig = nullptr;
void __fastcall Hook(int eventID, int /*edx*/) {
for (AutoSubscribe *s = g_head; s != nullptr; s = s->next)
if (s->fn(eventID))
return; // a subscriber handled it — suppress the original fire
g_orig(eventID, 0);
}
const Game::HookAutoRegister _hook{
Offsets::FUN_FIRE_EVENT_NO_ARGS, reinterpret_cast<void *>(&Hook),
reinterpret_cast<void **>(&g_orig)};
} // namespace
AutoSubscribe::AutoSubscribe(Interceptor f) : fn(f), next(g_head) { g_head = this; }
} // namespace Event::SignalHook
+47
View File
@@ -0,0 +1,47 @@
// 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
// Shared interceptor for the engine's no-arg event dispatcher
// `FUN_FIRE_EVENT_NO_ARGS` (`0x00703E50`) — the `__fastcall(int eventID)`
// path every no-arg event broadcast goes through (PLAYER_ENTERING_WORLD,
// LOOT_OPENED/CLOSED, zone changes, …), as opposed to the variadic
// `FUN_FIRE_EVENT` used by events that carry a printf-style payload.
//
// MinHook allows only ONE hook per target address, and more than one feature
// needs to intercept this dispatcher (loot silent-scan suppression, the
// PLAYER_ENTERING_WORLD payload). So — exactly like `Tick::WorldTick` for the
// per-frame tick — this module owns the single hook and fans out to
// subscribers. Declare a file-scope `static const
// Event::SignalHook::AutoSubscribe` in your module.
//
// An interceptor returns `true` when it has fully handled `eventID` and the
// engine's original no-arg fire must be SUPPRESSED — either because it
// swallowed the event (loot) or re-fired it a different way (the PEW payload
// re-broadcasts through the variadic `FUN_FIRE_EVENT` with args). Returning
// `false` lets the event fall through to the next subscriber and ultimately
// the original. The FIRST subscriber to return `true` wins and the original
// is skipped, so subscribers must claim disjoint event ids.
namespace Event::SignalHook {
using Interceptor = bool (*)(int eventID);
struct AutoSubscribe {
explicit AutoSubscribe(Interceptor fn);
Interceptor fn;
AutoSubscribe *next;
};
} // namespace Event::SignalHook
+64
View File
@@ -0,0 +1,64 @@
// 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/>.
// `ClosestGameObjectPosition(gameObjectID)` → `xPos, yPos, distance` — the
// world position of the nearest game object with the given
// game-object-template ID, and its distance from the player in yards.
// Returns nothing when none is found (the `MayReturnNothing` contract).
//
// The game-object analog of `ClosestUnitPosition`; same shared scan
// (Object::ClosestByEntry), just filtering on the GameObject GUID type
// (`0xF110`) and `TYPEMASK_GAMEOBJECT`. Same retail-vs-vanilla caveat:
// this returns the nearest CURRENTLY-VISIBLE object of that entry rather
// than reading retail's static starting-zone database (which vanilla
// doesn't have).
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "object/ClosestByEntry.h"
#include <cmath>
#include <cstdint>
namespace GameObject::ClosestPosition {
namespace {
int __fastcall Script_ClosestGameObjectPosition(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: ClosestGameObjectPosition(gameObjectID)");
return 0;
}
const auto entry = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const Object::ClosestByEntry::Result r = Object::ClosestByEntry::Find(
Offsets::TYPEMASK_GAMEOBJECT, Guid::Type::GameObject, entry);
if (!r.found)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(r.x));
Game::Lua::PushNumber(L, static_cast<double>(r.y));
Game::Lua::PushNumber(L, std::sqrt(static_cast<double>(r.distSq)));
return 3;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("ClosestGameObjectPosition",
&Script_ClosestGameObjectPosition);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace GameObject::ClosestPosition
+80
View File
@@ -0,0 +1,80 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "item/BagFamily.h"
#include "Offsets.h"
namespace Item::BagFamily {
namespace {
// Chained at static-init by each `AutoResolver` constructor, before
// `DllMain`. Zero-initialized (namespace static) ahead of any dynamic
// init, so the head is valid when resolvers link in.
AutoResolver *g_resolvers = nullptr;
// Stock vanilla bags/quivers that shipped with an empty `m_bagFamily`
// field, recovered from (class, subclass). Correspondence cross-referenced
// by name between ItemSubClass.dbc and ItemBagFamily.dbc. Everything else
// in stock vanilla either carries the field directly or has no family, so
// it isn't listed — server-custom bags come in through the resolver chain.
uint32_t StockSubclassToId(uint32_t itemClass, uint32_t subclass) {
if (itemClass == kItemClassContainer) {
switch (subclass) {
case 1: return 3; // Soul Bag → Soul Shards
case 2: return 6; // Herb Bag → Herbs
case 3: return 7; // Enchanting Bag → Enchanting Supplies
case 4: return 8; // Engineering Bag → Engineering Supplies
default: return 0;
}
}
if (itemClass == kItemClassQuiver) {
switch (subclass) {
case 2: return 1; // Quiver → Arrows
case 3: return 2; // Ammo Pouch → Bullets
default: return 0;
}
}
return 0;
}
} // namespace
AutoResolver::AutoResolver(Resolver f) : fn(f), next(g_resolvers) {
g_resolvers = this;
}
uint32_t SubclassToId(uint32_t itemClass, uint32_t subclass) {
if (const uint32_t id = StockSubclassToId(itemClass, subclass))
return id;
for (AutoResolver *r = g_resolvers; r != nullptr; r = r->next)
if (const uint32_t id = r->fn(itemClass, subclass))
return id;
return 0;
}
uint32_t BitmaskForRecord(const uint8_t *record) {
uint32_t rawId = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
if (rawId == 0) {
const uint32_t cls = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t sub = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
rawId = SubclassToId(cls, sub);
}
return IdToBitmask(rawId);
}
} // namespace Item::BagFamily
+72
View File
@@ -0,0 +1,72 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// Shared BagFamily derivation for a cached `ItemStats_C` record. Used by
// `C_Item.GetItemFamily` and `C_Container.GetContainerNumFreeSlots` /
// `CalculateTotalNumberOfFreeBagSlots` so the family a bag reports matches
// the family the items it holds report.
//
// This module knows only STOCK vanilla data. Server-custom container
// families (e.g. Turtle's cooking/gathering bags) plug in through the
// `AutoResolver` extension point below, so non-stock hardcoded data lives
// in its own module (see `src/turtle/`) rather than here.
namespace Item::BagFamily {
// Item.dbc `m_class` values. Containers hold the profession/soul bags;
// Quivers hold arrows (subclass 2) and bullets/ammo pouches (subclass 3).
// See ItemClass.dbc.
constexpr uint32_t kItemClassContainer = 1;
constexpr uint32_t kItemClassQuiver = 11;
// Convert 1.12's raw 1-based `ItemBagFamily.dbc` ID to the modern bitmask
// encoding (`1 << (id - 1)`); `0` stays `0`. Vanilla stores the raw ID
// (`arrow=1, bullet=2, soul shard=3, herb=6, …`); Wrath+ flipped the same
// field to the bitmask form, which is what backported addons expect.
inline uint32_t IdToBitmask(uint32_t rawId) {
return (rawId == 0) ? 0 : (1u << (rawId - 1));
}
// (class, subclass) → `ItemBagFamily.dbc` raw ID, for bags/quivers whose
// `m_bagFamily` field shipped empty. Covers the stock vanilla profession
// bags (Container class: Soul/Herb/Enchanting/Engineering) and quivers
// (Quiver class: Quiver→arrows, Ammo Pouch→bullets); for anything else it
// consults the registered custom resolvers (see `AutoResolver`) before
// giving up with `0`. Keyed on class because subclass numbers repeat
// across classes (Container subclass 2 = Herb Bag, Quiver subclass 2 =
// Quiver). Returns a raw family ID, not a bitmask.
uint32_t SubclassToId(uint32_t itemClass, uint32_t subclass);
// BagFamily bitmask for a cached `ItemStats_C` record: the item's
// `m_bagFamily` field if populated, otherwise — for a container whose
// field is empty — derived from the container subclass. Pass a non-null
// record.
uint32_t BitmaskForRecord(const uint8_t *record);
// Extension point for server-custom bag families. A resolver maps a
// (class, subclass) pair to an `ItemBagFamily.dbc` raw ID, or returns `0`
// if it doesn't recognize it. Consulted by `SubclassToId` only after the
// stock table misses. Declare a file-scope
// `static const Item::BagFamily::AutoResolver` in a platform module to
// register one at static-init time.
using Resolver = uint32_t (*)(uint32_t itemClass, uint32_t subclass);
struct AutoResolver {
explicit AutoResolver(Resolver fn);
Resolver fn;
AutoResolver *next;
};
} // namespace Item::BagFamily
+11
View File
@@ -11,6 +11,8 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "item/Count.h"
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
@@ -251,6 +253,15 @@ int __fastcall Script_C_Item_GetItemCount(void *L) {
} // namespace
int InInventory(void *L, int itemID) {
if (itemID <= 0)
return 0;
int total = CountEquipped(itemID, /*includeUses*/ false);
for (int bag = 0; bag <= 4; bag++)
total += CountInBag(L, bag, itemID, /*includeUses*/ false);
return total;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "GetItemCount",
&Script_C_Item_GetItemCount);
+25
View File
@@ -0,0 +1,25 @@
// 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 Item::Count {
// Total plain stack count of `itemID` across the player's equipped slots
// and bags 0..4 (no bank, no charge multiplier) — the "carried on me"
// count. Needs the Lua state `L`: the bag walk uses the engine's slot
// packer, which reads and clears the Lua stack, so the caller must have
// already consumed any stack arguments it still needs before calling.
int InInventory(void *L, int itemID);
} // namespace Item::Count
+13 -18
View File
@@ -15,6 +15,7 @@
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "item/Arg.h"
#include "item/BagFamily.h"
#include "item/Data.h"
#include "item/ID.h"
#include "item/Link.h"
@@ -192,17 +193,6 @@ static int __fastcall Script_C_Item_GetItemIconByID(void *L) {
return PushIconForItemID(L, Item::Arg::ResolveItemID(L, 1));
}
// Convert 1.12's raw `BagFamily` ID to the modern bitmask encoding.
// Vanilla stores `arrow=1, bullet=2, soul shard=3, herb=6, ...`; Wrath
// flipped to `1 << (ID-1)` (`arrow=0x1, bullet=0x2, soul shard=0x4,
// herb=0x20, ...`) and addons backported from Wrath+ expect the
// bitmask. We always surface the modern shape so callers don't have to
// branch on client version. See `Offsets::OFF_ITEMSTATS_BAG_FAMILY`
// for the empirical verification.
uint32_t BagFamilyIdToBitmask(uint32_t rawId) {
return (rawId == 0) ? 0 : (1u << (rawId - 1));
}
// `C_Item.GetItemFamily(item)` — returns the BagFamily bitmask for an
// item (modern encoding: `1 << (ID-1)`), or `nil` if the item isn't
// in the cache. Used by auto-routing addons that decide which
@@ -227,10 +217,14 @@ uint32_t BagFamilyIdToBitmask(uint32_t rawId) {
// but has family 0 (general-purpose)". Both are safe to treat as "no
// preference" but keeping them distinct helps debugging.
//
// 1.12 vanilla server data sparsely populates the field for **bags**
// themselves (only quivers seem to carry it on Turtle WoW). Individual
// loot items do carry the field reliably (arrows, bullets, shards,
// herbs all return their proper raw IDs).
// 1.12 vanilla server data leaves the `m_bagFamily` field empty on the
// **bags/quivers** themselves — Soul/Herb/Enchanting/Engineering bags AND
// quivers/ammo pouches all read 0. Individual loot items do carry the
// field reliably (arrows, bullets, shards, herbs return their proper raw
// IDs). For a bag whose field is empty we recover the family from its
// class + subclass (the same signal the "Soul Bag" tooltip line uses), so
// a Felcloth Bag reports the Soul Shard family (0x4), a quiver 0x1, an
// ammo pouch 0x2 — see `Item::BagFamily`.
static int __fastcall Script_C_Item_GetItemFamily(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0)
@@ -240,9 +234,10 @@ static int __fastcall Script_C_Item_GetItemFamily(void *L) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
}
const uint32_t rawId = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
Game::Lua::PushNumber(L, static_cast<double>(BagFamilyIdToBitmask(rawId)));
// Family field if populated, else derived from the container subclass
// (Turtle leaves bags' m_bagFamily at 0 — see Item::BagFamily).
Game::Lua::PushNumber(
L, static_cast<double>(Item::BagFamily::BitmaskForRecord(record)));
return 1;
}
+89 -1
View File
@@ -13,6 +13,7 @@
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstdint>
@@ -25,6 +26,18 @@ int LocaleIndex() {
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
}
// Localized ItemClass.dbc name for a class ID (records indexed by ID, the
// standard DBC shape — same lookup `Script_GetItemInfo` uses). Returns ""
// for an unknown class. Obsolete slots keep the client's literal string
// (classID 3 → "Jewelry(OBSOLETE)", 8 → "Generic(OBSOLETE)", …) — that's
// the real vanilla data; Enum.ItemClass supplies the modern key names.
const char *ClassName(uint32_t classID) {
const char *s = DBC::LocalizedField(Offsets::VAR_ITEMCLASS_RECORDS,
Offsets::VAR_ITEMCLASS_COUNT, classID,
Offsets::OFF_ITEMCLASS_NAMES);
return (s != nullptr && s[0] != '\0') ? s : "";
}
// Linear-scan ItemSubClass.dbc for the (classID, subClassID) compound-key
// row — same scan Item::GetData uses. Returns the record or nullptr.
const uint8_t *FindSubClass(uint32_t classID, uint32_t subClassID) {
@@ -72,8 +85,27 @@ const char *InvTypeKey(int invType) {
return (s != nullptr && s[0] != '\0') ? s : "";
}
// `GetItemClassInfo(classID)` → `className` (localized). `nil` for an
// unknown class. Backport of the modern global (also exposed as
// `C_Item.GetItemClassInfo`).
int __fastcall Script_GetItemClassInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetItemClassInfo(classID)");
return 0;
}
const char *name = ClassName(static_cast<uint32_t>(Game::Lua::ToNumber(L, 1)));
if (name[0] == '\0') {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushString(L, name);
return 1;
}
// `C_Item.GetItemSubClassInfo(classID, subClassID)` →
// `subClassName, subClassUsesInvType`.
// `subClassName, subClassUsesInvType`. Also exposed as the global
// `GetItemSubClassInfo(classID, subClassID)` → `name, isArmorType`, the
// same two values (the second is true for the armor material subclasses).
//
// `subClassName` is the localized ItemSubClass.dbc name (verbose form, e.g.
// "One-Handed Swords"; falls back to the short form for subclasses that
@@ -184,7 +216,57 @@ constexpr Game::Lua::EnumIntegerEntry kInventoryTypeEntries[] = {
{"IndexEquipablespellWeaponType", 34},
};
// `Enum.ItemClass` — the modern item-class enum. Values 0..15 are the
// classes vanilla items actually report (the numeric IDs match retail; the
// obsolete slots 3/8/10/14 keep their modern key names — Gem /
// ItemEnhancement / CurrencyTokenObsolete / PermanentObsolete — even
// though ItemClass.dbc labels them "…(OBSOLETE)"). 16..19 are post-vanilla
// and never appear in 1.12 item data, included so backport code that
// references those keys resolves (comparisons just never match).
constexpr Game::Lua::EnumIntegerEntry kItemClassEntries[] = {
{"Consumable", 0},
{"Container", 1},
{"Weapon", 2},
{"Gem", 3},
{"Armor", 4},
{"Reagent", 5},
{"Projectile", 6},
{"Tradegoods", 7},
{"ItemEnhancement", 8},
{"Recipe", 9},
{"CurrencyTokenObsolete", 10},
{"Quiver", 11},
{"Questitem", 12},
{"Key", 13},
{"PermanentObsolete", 14},
{"Miscellaneous", 15},
{"Glyph", 16},
{"Battlepet", 17},
{"WoWToken", 18},
{"Profession", 19},
};
// `Enum.ItemQuality` — the numeric `quality` reported by `GetItemInfo` /
// `C_Item.GetItemQuality`. Values 0..6 are all vanilla ever uses; the
// post-vanilla Heirloom (7) and WoWToken (8) tiers are omitted (no such
// items exist on 1.12).
constexpr Game::Lua::EnumIntegerEntry kItemQualityEntries[] = {
{"Poor", 0},
{"Common", 1},
{"Uncommon", 2},
{"Rare", 3},
{"Epic", 4},
{"Legendary", 5},
{"Artifact", 6},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetItemClassInfo",
&Script_GetItemClassInfo);
Game::Lua::RegisterGlobalFunction("GetItemSubClassInfo",
&Script_GetItemSubClassInfo);
Game::Lua::RegisterTableFunction("C_Item", "GetItemClassInfo",
&Script_GetItemClassInfo);
Game::Lua::RegisterTableFunction("C_Item", "GetItemSubClassInfo",
&Script_GetItemSubClassInfo);
Game::Lua::RegisterTableFunction("C_Item", "GetItemInventorySlotKey",
@@ -194,6 +276,12 @@ void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum(
"Enum", "InventoryType", kInventoryTypeEntries,
sizeof(kInventoryTypeEntries) / sizeof(kInventoryTypeEntries[0]));
Game::Lua::RegisterIntegerEnum(
"Enum", "ItemClass", kItemClassEntries,
sizeof(kItemClassEntries) / sizeof(kItemClassEntries[0]));
Game::Lua::RegisterIntegerEnum(
"Enum", "ItemQuality", kItemQualityEntries,
sizeof(kItemQualityEntries) / sizeof(kItemQualityEntries[0]));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+10 -14
View File
@@ -27,6 +27,7 @@
#include "../Game.h"
#include "../Offsets.h"
#include "../event/Custom.h"
#include "../event/SignalHook.h"
#include "../guid/Guid.h"
#include "../tick/WorldTick.h"
@@ -115,9 +116,6 @@ using LootSlotLinkBuilder_t = const char *(__fastcall *)(uint32_t userFacingSlot
using LootController_t = void(__fastcall *)(int lootStructPtr,
int coin,
int unk);
using FireEventNoArgs_t = void(__fastcall *)(int eventID,
int /*edx unused*/);
struct C3Vector {
float x, y, z;
};
@@ -256,7 +254,6 @@ void StartLoot(uint64_t guid) {
// === Hooks ===
LootController_t s_lootController_o = nullptr;
FireEventNoArgs_t s_fireEventNoArgs_o = nullptr;
// `LOOT_OPENED` / `LOOT_CLOSED` get suppressed only while a scan is
// actively interleaving open/close cycles, signaled by
@@ -265,12 +262,14 @@ FireEventNoArgs_t s_fireEventNoArgs_o = nullptr;
// `SendCloseLoot` that follows, then dropped before
// `TryStartNext`-initiated CMSG_LOOTs go out. Every other engine
// event fire — and normal play's loot events — pass through.
void __fastcall FireEventNoArgs_h(int eventID, int /*unusedEdx*/) {
if (s_scan.suppressEvents &&
(eventID == EVENT_LOOT_OPENED || eventID == EVENT_LOOT_CLOSED)) {
return;
}
s_fireEventNoArgs_o(eventID, 0);
//
// Runs as an interceptor on the shared no-arg event hook
// (`Event::SignalHook`) rather than owning the hook directly, so it can
// coexist with other features that intercept the same dispatcher (e.g. the
// PLAYER_ENTERING_WORLD payload). Return true to swallow.
bool SuppressLootEvents(int eventID) {
return s_scan.suppressEvents &&
(eventID == EVENT_LOOT_OPENED || eventID == EVENT_LOOT_CLOSED);
}
// Intercepts the engine's loot-window state controller. Two paths:
@@ -336,10 +335,7 @@ const Game::HookAutoRegister _hookController{
reinterpret_cast<void *>(&LootController_h),
reinterpret_cast<void **>(&s_lootController_o)};
const Game::HookAutoRegister _hookFireEvent{
Offsets::FUN_FIRE_EVENT_NO_ARGS,
reinterpret_cast<void *>(&FireEventNoArgs_h),
reinterpret_cast<void **>(&s_fireEventNoArgs_o)};
const Event::SignalHook::AutoSubscribe _lootIntercept{&SuppressLootEvents};
// === Tick (timeout only) ===
+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
+105
View File
@@ -0,0 +1,105 @@
// 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 "object/ClosestByEntry.h"
#include "Game.h"
#include "Offsets.h"
#include "unit/Position.h"
#include <cstdint>
namespace Object::ClosestByEntry {
namespace {
// `ClntObjMgrEnumVisibleObjects(cb, ctx)` — walks the visible-object
// table; the callback's dummy EDX slot absorbs the second __fastcall
// register so the trailing `uint64_t guid` lands where the engine puts it.
// Return non-zero to keep iterating.
using EnumCallback_t = int(__fastcall *)(void *ctx, void *unusedEdx,
uint64_t guid);
using EnumVisibleObjects_t = int(__fastcall *)(EnumCallback_t cb, void *ctx);
// `ClntObjMgrObjectPtr(typeMask, dbg, guidLo, guidHi, dbgCode)` — resolves
// a GUID to its live `CGObject_C *`, filtered by typeMask; NULL if none.
using ObjectPtr_t = void *(__fastcall *)(uint32_t typeMask, const char *dbg,
uint32_t guidLo, uint32_t guidHi,
int dbgCode);
struct ScanCtx {
uint32_t typeMask;
Guid::Type guidType;
uint32_t wantEntry;
float px, py, pz; // player position
Result r;
};
int __fastcall ScanCallback(ScanCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
// Type + entry checks are pure GUID bit-reads, so we skip the (costly)
// object resolve for everything that can't match.
if (Guid::Classify(guid) != ctx->guidType)
return 1;
const uint32_t entry = static_cast<uint32_t>((guid >> 24) & 0xFFFFFFu);
if (entry != ctx->wantEntry)
return 1;
auto ObjectPtr =
reinterpret_cast<ObjectPtr_t>(Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *obj = ObjectPtr(ctx->typeMask, nullptr, static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (obj == nullptr)
return 1;
float pos[3];
if (!Unit::Position::Read(obj, pos))
return 1;
const float dx = pos[0] - ctx->px;
const float dy = pos[1] - ctx->py;
const float dz = pos[2] - ctx->pz;
const float distSq = dx * dx + dy * dy + dz * dz;
if (!ctx->r.found || distSq < ctx->r.distSq) {
ctx->r.found = true;
ctx->r.x = pos[0];
ctx->r.y = pos[1];
ctx->r.distSq = distSq;
}
return 1;
}
} // namespace
Result Find(uint32_t typeMask, Guid::Type guidType, uint32_t entry) {
const Result none{false, 0.0f, 0.0f, 0.0f};
if (entry == 0)
return none;
// The enumerator derefs the object manager unconditionally; it's NULL
// during glue / character-select. Bail rather than crash.
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) ==
nullptr)
return none;
float ppos[3];
if (!Unit::Position::Read(Unit::Position::ResolveToken("player"), ppos))
return none;
ScanCtx ctx{typeMask, guidType, entry, ppos[0], ppos[1], ppos[2], none};
auto Enum = reinterpret_cast<EnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
Enum(reinterpret_cast<EnumCallback_t>(&ScanCallback), &ctx);
return ctx.r;
}
} // namespace Object::ClosestByEntry
+41
View File
@@ -0,0 +1,41 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include "guid/Guid.h"
#include <cstdint>
// Shared "nearest visible object of a given template entry" scan, backing
// both `ClosestUnitPosition` (creatures) and `ClosestGameObjectPosition`
// (game objects). Vanilla packs the template entry into GUID bits 24-47
// for the world-object types, so the only per-caller differences are the
// GUID type prefix and the object-manager type mask.
namespace Object::ClosestByEntry {
struct Result {
bool found;
float x, y; // world position of the nearest match
float distSq; // squared center-to-center distance from the player
};
// Nearest CURRENTLY-VISIBLE object whose GUID is of `guidType` (Creature /
// GameObject / …) and encodes template `entry` (GUID bits 24-47),
// relative to the local player. `typeMask` is the `ClntObjMgrObjectPtr`
// filter used to resolve the GUID (`TYPEMASK_UNIT` / `TYPEMASK_GAMEOBJECT`).
// Returns `{found=false}` for entry 0, pre-world, on a player-position
// miss, or when nothing matches.
Result Find(uint32_t typeMask, Guid::Type guidType, uint32_t entry);
} // namespace Object::ClosestByEntry
+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
+12 -10
View File
@@ -63,6 +63,7 @@
#include "item/Location.h"
#include "item/StatAccum.h"
#include "player/StatSignal.h"
#include "spell/HealingAura.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
@@ -139,18 +140,19 @@ long ItemFlatHealing(const uint8_t *cgItem) {
// --- Buff / talent healing auras (flat + Spirit/Armor conversions) ---
// One decoder for every healing aura a buff or passive talent can carry,
// mirroring the server's SpellBaseHealingBonusDone:
// mirroring the server's SpellBaseHealingBonusDone. Stock vanilla auras are
// handled inline:
// 135 MOD_HEALING_DONE → flat += amount
// 175 MOD_SPELL_HEALING_OF_STAT_PERCENT → += Spirit × amount / 100 (Spiritual Guidance)
// 199 MOD_SPELL_HEALING_OF_ARMOR_PERCENT → += Armor × amount / 100 (Turtle Ironclad)
// Amounts/percents are read straight from Spell.dbc — generic (any talent/buff
// using these auras, incl. Turtle customs) and robust (no client-vs-server
// value delta). `requirePassive` gates the bitmap walk to always-on talents,
// so a *known but not-currently-cast* buff (Divine Spirit) isn't counted as
// on; active buffs pass `requirePassive == false`.
// Any other aura index is deferred to `Spell::HealingAura::Resolve`, the
// extension point where server-custom healing auras live (e.g. Turtle's
// Ironclad, aura 199 = × Armor — see src/turtle/HealingAura.cpp). Amounts/
// percents are read straight from Spell.dbc — generic and robust (no
// client-vs-server value delta). `requirePassive` gates the bitmap walk to
// always-on talents, so a *known but not-currently-cast* buff (Divine
// Spirit) isn't counted as on; active buffs pass `requirePassive == false`.
constexpr int kAuraModHealingDone = 135;
constexpr int kAuraModHealingOfStatPercent = 175; // × Spirit
constexpr int kAuraModHealingOfArmorPercent = 199; // × Armor (Turtle Ironclad)
void AddSpellHealingAuras(long &total, int spellID, int32_t spirit,
int32_t armor, bool requirePassive) {
@@ -177,8 +179,8 @@ void AddSpellHealingAuras(long &total, int spellID, int32_t spirit,
total += amount;
else if (aura[i] == kAuraModHealingOfStatPercent)
total += static_cast<long>(spirit) * amount / 100;
else if (aura[i] == kAuraModHealingOfArmorPercent)
total += static_cast<long>(armor) * amount / 100;
else
total += Spell::HealingAura::Resolve(aura[i], amount, spirit, armor);
}
}
+37
View File
@@ -0,0 +1,37 @@
// 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 "spell/HealingAura.h"
namespace Spell::HealingAura {
namespace {
// Chained at static-init by each `AutoResolver` constructor, before
// `DllMain`. Zero-initialized ahead of any dynamic init.
AutoResolver *g_resolvers = nullptr;
} // namespace
AutoResolver::AutoResolver(Resolver f) : fn(f), next(g_resolvers) {
g_resolvers = this;
}
long Resolve(int auraIndex, long amount, int32_t spirit, int32_t armor) {
for (AutoResolver *r = g_resolvers; r != nullptr; r = r->next)
if (const long v = r->fn(auraIndex, amount, spirit, armor))
return v;
return 0;
}
} // namespace Spell::HealingAura
+45
View File
@@ -0,0 +1,45 @@
// 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>
// Extension point for non-stock healing-aura semantics feeding
// `GetSpellBonusHealing`. The stock vanilla healing auras — 135
// (MOD_HEALING_DONE, flat) and 175 (MOD_SPELL_HEALING_OF_STAT_PERCENT,
// ×Spirit) — are decoded inline in `spell/BonusDamage.cpp`. Server-custom
// aura effects (e.g. Turtle's Ironclad, aura 199 = ×Armor) register a
// resolver here so their meaning lives outside the stock decoder.
namespace Spell::HealingAura {
// Flat healing contribution for a single Spell.dbc aura effect: the aura
// index plus its computed amount (base + dice), and the player's Spirit /
// Armor for stat-scaled auras. Returns 0 for aura indices no resolver
// recognizes. Consulted by the stock decoder for any effect that isn't a
// stock healing aura.
long Resolve(int auraIndex, long amount, int32_t spirit, int32_t armor);
// A resolver maps one aura effect to its flat healing contribution, or
// returns 0 if it doesn't recognize the aura index. Declare a file-scope
// `static const Spell::HealingAura::AutoResolver` in a platform module to
// register one at static-init time.
using Resolver = long (*)(int auraIndex, long amount, int32_t spirit,
int32_t armor);
struct AutoResolver {
explicit AutoResolver(Resolver fn);
Resolver fn;
AutoResolver *next;
};
} // namespace Spell::HealingAura
+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
+546
View File
@@ -0,0 +1,546 @@
// 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/>.
// `GetTotemInfo(slot)` — the four-element totem bar, backported. Vanilla
// 1.12 tracks nothing about the shaman's totems client-side (the totem
// bar, PLAYER_TOTEM_UPDATE, and GetTotemInfo are all TBC additions), so
// this is a self-tracking subsystem — but a fully DATA-DRIVEN one:
//
// * SLOT is read straight from the totem summon spell's `Spell.dbc`
// effect: 87/88/89/90 = SUMMON_TOTEM_SLOT1..4 = Fire/Earth/Water/Air,
// so `slot = effect - 86`. No hardcoded spell table; Turtle custom
// totems self-classify.
// * CREATURE ENTRY is the effect's `EffectMiscValue` (the summoned
// totem's creature-template ID) — used to detect the totem's death.
// * DURATION is the summon spell's `SpellDuration.dbc` value (via the
// engine's own duration helper, so player duration mods apply).
//
// Detection: the player's totem cast fires SMSG_SPELL_GO, which
// `Aura::Source` already co-hooks; it calls `OnPlayerSpellGo` here (before
// its aura gate, since a summon applies no aura). We stamp the slot with
// start time + duration.
//
// Death / removal: on WorldTick we scan the object manager for a
// player-owned creature of the slot's entry. A "seen once, then gone"
// latch clears the slot early (totem killed, Totemic Recall, or manually
// destroyed) — the owner check (CreatedBy == player) means another
// shaman's same-entry totem can't keep ours alive, and the seen-latch
// tolerates the ~1-tick spawn lag before the totem appears. Duration is
// the backstop for the normal case. A recast into the same slot simply
// re-stamps it.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "event/SignalHook.h"
#include "guid/Guid.h"
#include "item/Count.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Totem::Tracker {
namespace {
// Spell.dbc record field offsets (see spell/Info.cpp for the shared set).
constexpr int OFF_EFFECT = 0xF4; // int32[3] — SPELL_EFFECT_*
constexpr int OFF_TOTEM_TOOL = 0xA0; // int32[2] — Totem (required tool item IDs)
constexpr int OFF_MISC_VALUE = 0x1A8; // int32[3] — EffectMiscValue
constexpr int OFF_ICON_ID = 0x1D4; // int32 — SpellIconID
constexpr int OFF_NAME = 0x1E0; // char*[9] locale array
// SUMMON_TOTEM_SLOT1..4 spell effects (verified against Spell.dbc: Searing
// 87/Fire, Stoneskin 88/Earth, Healing Stream 89/Water, Windfury 90/Air).
constexpr int kEffectSummonTotemSlot1 = 87;
constexpr int kEffectSummonTotemSlot4 = 90;
constexpr int kSlots = 4;
// Re-scan the object manager for totem existence at most this often.
constexpr uint32_t kScanIntervalMs = 250;
struct Slot {
bool active;
bool seen; // the totem creature has been observed in the world
uint32_t spellID;
uint32_t entry; // summoned creature-template ID (for death detection)
uint32_t startMs; // FUN_OS_TICKCOUNT_MS (shares GetTime's epoch × 1000)
uint32_t durationMs;
};
Slot g_slots[kSlots] = {};
uint32_t g_lastScanMs = 0;
// Per-slot totem tool item ID (Fire/Earth/Water/Air), read from the totem
// spell's `Totem[0]` field — the required TOOL the spell lists (Earth
// Totem, Fire Totem, …), NOT a consumed reagent, and NOT hardcoded. Filled
// from the player's spellbook on SPELLS_CHANGED and refreshed at cast; 0 =
// not yet resolved. `g_spellsChangedId` caches the event id.
uint32_t g_toolItem[kSlots] = {};
int g_spellsChangedId = -1;
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
}
// The totem tool item a spell requires (`Totem[0]`), or 0.
uint32_t TotemToolItem(const uint8_t *rec) {
const int32_t t = *reinterpret_cast<const int32_t *>(rec + OFF_TOTEM_TOOL);
return t > 0 ? static_cast<uint32_t>(t) : 0;
}
// Fill any still-unknown slot tool items by scanning the player's spellbook
// for totem summons and reading each one's required tool. Tools are fixed
// per element, so once a slot is resolved it never changes and the scan
// early-outs. Walks the flat learned-spell array `VAR_PLAYER_SPELLBOOK`
// (the same list `GetSpellName` reads) — the `IsPlayerSpell` bitmap
// doesn't reliably carry totems. Driven by SPELLS_CHANGED (when the
// spellbook is populated), so no polling.
void ScanTools() {
bool anyUnknown = false;
for (int s = 0; s < kSlots; ++s)
if (g_toolItem[s] == 0) {
anyUnknown = true;
break;
}
if (!anyUnknown)
return;
auto *book = reinterpret_cast<const int32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELLBOOK));
for (int slot = 0; slot < Offsets::SPELLBOOK_MAX_SLOTS; ++slot) {
const int spellID = book[slot];
if (spellID <= 0)
continue;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
continue;
const auto *eff = reinterpret_cast<const int32_t *>(rec + OFF_EFFECT);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const int e = eff[i];
if (e < kEffectSummonTotemSlot1 || e > kEffectSummonTotemSlot4)
continue;
const int s = e - kEffectSummonTotemSlot1;
if (g_toolItem[s] == 0)
g_toolItem[s] = TotemToolItem(rec);
break;
}
}
}
// Re-resolve tool items whenever the player's spells (re)load.
// SPELLS_CHANGED is a no-arg event through the same dispatcher
// Event::SignalHook owns, so we observe it there (never suppress — return
// false). By the time it fires the spellbook is populated, so the scan
// sees the totems.
bool OnSignal(int eventID) {
if (g_spellsChangedId < 0)
g_spellsChangedId = Event::Custom::LookupByName("SPELLS_CHANGED");
if (eventID == g_spellsChangedId)
ScanTools();
return false;
}
const Event::SignalHook::AutoSubscribe _spellsChanged{&OnSignal};
uint32_t SpellDurationMs(const uint8_t *rec) {
using GetDuration_t = int(__fastcall *)(const uint8_t *rec, int unit,
char skipMod);
const int ms = reinterpret_cast<GetDuration_t>(
static_cast<uintptr_t>(Offsets::FUN_GET_SPELL_DURATION))(rec, 0, 0);
return ms > 0 ? static_cast<uint32_t>(ms) : 0;
}
// ---- object-manager existence scan (player-owned creature by entry) ------
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);
struct ScanCtx {
uint64_t playerGuid;
uint32_t entries[kSlots]; // 0 = slot not being checked
bool present[kSlots];
};
int __fastcall ScanCallback(ScanCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
if (!Guid::IsCreature(guid))
return 1;
const uint32_t entry = static_cast<uint32_t>((guid >> 24) & 0xFFFFFFu);
if (entry == 0)
return 1;
int match = -1;
for (int s = 0; s < kSlots; ++s) {
if (ctx->entries[s] == entry) {
match = s;
break;
}
}
if (match < 0)
return 1;
// Confirm it's OUR totem: resolve + check CreatedBy == player.
auto ObjectPtr =
reinterpret_cast<ObjectPtr_t>(Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *obj = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (obj == nullptr)
return 1;
auto *desc = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(obj) + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 1;
const uint64_t createdBy = *reinterpret_cast<const uint64_t *>(
desc + Offsets::OFF_UNIT_FIELD_CREATEDBY);
if (createdBy == ctx->playerGuid)
ctx->present[match] = true;
return 1; // keep scanning — other slots may match other creatures
}
// On-demand lookup of the live GUID of the player's totem creature of a
// given entry (for TargetTotem). Returns 0 when none is visible.
struct FindGuidCtx {
uint64_t playerGuid;
uint32_t entry;
uint64_t found;
};
int __fastcall FindGuidCallback(FindGuidCtx *ctx, void * /*unusedEdx*/,
uint64_t guid) {
if (!Guid::IsCreature(guid))
return 1;
if (static_cast<uint32_t>((guid >> 24) & 0xFFFFFFu) != ctx->entry)
return 1;
auto ObjectPtr =
reinterpret_cast<ObjectPtr_t>(Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *obj = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (obj == nullptr)
return 1;
auto *desc = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(obj) + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 1;
if (*reinterpret_cast<const uint64_t *>(
desc + Offsets::OFF_UNIT_FIELD_CREATEDBY) == ctx->playerGuid) {
ctx->found = guid;
return 0; // stop
}
return 1;
}
uint64_t FindTotemGuid(uint32_t entry) {
if (entry == 0)
return 0;
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) ==
nullptr)
return 0;
const uint64_t player = Unit::Identity::PlayerGuid();
if (player == 0)
return 0;
FindGuidCtx ctx{player, entry, 0};
auto Enum = reinterpret_cast<EnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
Enum(reinterpret_cast<EnumCallback_t>(&FindGuidCallback), &ctx);
return ctx.found;
}
// PLAYER_TOTEM_UPDATE(totemSlot 1-4) — a TBC event absent from vanilla's
// table, reserved as a custom event and fired when a slot's state changes
// (totem dropped, expired, killed, or recalled). Changes are recorded in a
// dirty mask by the cast path and the clear paths, then flushed from OnTick
// (WorldTick context — safe to run Lua handlers, unlike mid-packet).
constexpr const char *kPlayerTotemUpdate = "PLAYER_TOTEM_UPDATE";
const Event::Custom::AutoReserve _totemUpdate{kPlayerTotemUpdate};
int g_totemUpdateId = -1;
uint8_t g_dirtyMask = 0;
void MarkDirty(int slotIdx) {
g_dirtyMask |= static_cast<uint8_t>(1u << slotIdx);
}
void FireDirty() {
if (g_dirtyMask == 0)
return;
if (g_totemUpdateId < 0)
g_totemUpdateId = Event::Custom::LookupByName(kPlayerTotemUpdate);
if (g_totemUpdateId < 0)
return; // slot not claimed yet — keep dirty, retry next tick
for (int s = 0; s < kSlots; ++s)
if (g_dirtyMask & (1u << s))
Event::Custom::Fire(g_totemUpdateId, "%d", s + 1);
g_dirtyMask = 0;
}
void OnTick() {
const uint32_t now = NowMs();
// Duration backstop — always cheap, runs every tick.
bool anyActive = false;
for (int s = 0; s < kSlots; ++s) {
Slot &t = g_slots[s];
if (!t.active)
continue;
if (t.durationMs != 0 && now - t.startMs >= t.durationMs) {
t.active = false;
MarkDirty(s);
continue;
}
anyActive = true;
}
// Death / removal scan — throttled, and only while a slot is up. The
// object-manager enumerator derefs the manager unconditionally, so it's
// gated on the local player existing.
if (anyActive && now - g_lastScanMs >= kScanIntervalMs &&
*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) !=
nullptr) {
const uint64_t player = Unit::Identity::PlayerGuid();
if (player != 0) {
g_lastScanMs = now;
ScanCtx ctx{};
ctx.playerGuid = player;
for (int s = 0; s < kSlots; ++s)
ctx.entries[s] = g_slots[s].active ? g_slots[s].entry : 0;
auto Enum = reinterpret_cast<EnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
Enum(reinterpret_cast<EnumCallback_t>(&ScanCallback), &ctx);
for (int s = 0; s < kSlots; ++s) {
Slot &t = g_slots[s];
if (!t.active)
continue;
if (ctx.present[s])
t.seen = true;
else if (t.seen) {
t.active = false; // was up, now gone → died / recalled
MarkDirty(s);
}
}
}
}
FireDirty();
}
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
// SpellIcon.dbc path for an icon ID (records indexed by ID; path at +4).
const char *IconPath(int iconID) {
if (iconID <= 0)
return nullptr;
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_ICON_COUNT));
if (iconID > count)
return nullptr;
auto *const *records = *reinterpret_cast<const uint8_t *const *const *>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_ICON_RECORDS));
if (records == nullptr || records[iconID] == nullptr)
return nullptr;
return *reinterpret_cast<const char *const *>(records[iconID] + 4);
}
// Pushes the empty tail (no active totem) after `haveTotem` has been
// pushed by the caller. Returns the total count including that first value.
int PushEmptyTail(void *L) {
Game::Lua::PushString(L, ""); // totemName
Game::Lua::PushNumber(L, 0.0); // startTime
Game::Lua::PushNumber(L, 0.0); // duration
Game::Lua::PushNil(L); // icon
Game::Lua::PushNumber(L, 1.0); // modRate
Game::Lua::PushNumber(L, 0.0); // spellID
return 7;
}
// `GetTotemInfo(slot)` → `haveTotem, totemName, startTime, duration, icon,
// modRate, spellID`. Slots: 1 Fire, 2 Earth, 3 Water, 4 Air. `haveTotem`
// is TOOL presence (the documented meaning) — whether the player carries
// the slot's totem tool item (Earth/Fire/Water/Air Totem) — NOT whether a
// totem is summoned; the active totem is signaled by a non-empty
// `totemName` / non-zero `startTime`.
int __fastcall Script_GetTotemInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetTotemInfo(slot)");
return 0;
}
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (slot < 1 || slot > kSlots) {
Game::Lua::PushBool(L, false);
return PushEmptyTail(L);
}
// Tool presence — independent of the summoned state. The tool item
// (resolved from the totem spell's Totem[0] on SPELLS_CHANGED, not
// hardcoded) is checked against the bag. The bag walk reads the Lua
// stack, so it runs before we push any return values (`slot` captured).
const uint32_t tool = g_toolItem[slot - 1];
const bool haveTotem =
tool != 0 && Item::Count::InInventory(L, static_cast<int>(tool)) > 0;
const Slot &t = g_slots[slot - 1];
if (!t.active) {
Game::Lua::PushBool(L, haveTotem);
return PushEmptyTail(L);
}
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(t.spellID));
const char *name = "";
const char *icon = nullptr;
if (rec != nullptr) {
const int locale =
*reinterpret_cast<const int *>(static_cast<uintptr_t>(
Offsets::VAR_LOCALE_INDEX));
const char *n = *reinterpret_cast<const char *const *>(
rec + OFF_NAME + locale * 4);
if (n != nullptr)
name = n;
icon = IconPath(*reinterpret_cast<const int *>(rec + OFF_ICON_ID));
}
Game::Lua::PushBool(L, haveTotem);
Game::Lua::PushString(L, name);
Game::Lua::PushNumber(L, static_cast<double>(t.startMs) * 0.001);
Game::Lua::PushNumber(L, static_cast<double>(t.durationMs) / 1000.0);
if (icon != nullptr)
Game::Lua::PushString(L, icon);
else
Game::Lua::PushNil(L);
Game::Lua::PushNumber(L, 1.0); // modRate — no per-totem haste in 1.12
Game::Lua::PushNumber(L, static_cast<double>(t.spellID));
return 7;
}
// `GetTotemTimeLeft(slot)` → seconds until the slot's totem is auto-
// destroyed, or `0` when no totem is active in that slot. Slots: 1 Fire,
// 2 Earth, 3 Water, 4 Air.
int __fastcall Script_GetTotemTimeLeft(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetTotemTimeLeft(slot)");
return 0;
}
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
double seconds = 0.0;
if (slot >= 1 && slot <= kSlots) {
const Slot &t = g_slots[slot - 1];
if (t.active && t.durationMs != 0) {
const uint32_t now = NowMs();
const uint32_t end = t.startMs + t.durationMs;
if (now < end)
seconds = static_cast<double>(end - now) / 1000.0;
}
}
Game::Lua::PushNumber(L, seconds);
return 1;
}
// `GetTotemDuration(slot)` → the total duration (seconds) of the slot's
// active totem, or `0` when none. This is the totem's full lifetime from
// cast (the 4th value of GetTotemInfo); see GetTotemTimeLeft for the
// remaining time.
int __fastcall Script_GetTotemDuration(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetTotemDuration(slot)");
return 0;
}
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
double seconds = 0.0;
if (slot >= 1 && slot <= kSlots) {
const Slot &t = g_slots[slot - 1];
if (t.active)
seconds = static_cast<double>(t.durationMs) / 1000.0;
}
Game::Lua::PushNumber(L, seconds);
return 1;
}
// `TargetTotem(slot)` — target the player's totem in the given slot
// (1 Fire, 2 Earth, 3 Water, 4 Air). No-op when the slot has no active
// totem or the totem creature isn't currently visible. Finds the totem
// creature's live GUID and sets the target via the same engine path
// `TargetUnit` uses.
int __fastcall Script_TargetTotem(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: TargetTotem(slot)");
return 0;
}
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (slot < 1 || slot > kSlots)
return 0;
const Slot &t = g_slots[slot - 1];
if (!t.active || t.entry == 0)
return 0;
uint64_t guid = FindTotemGuid(t.entry);
if (guid == 0)
return 0;
using TargetByGuid_t = void(__fastcall *)(const uint64_t *guid);
reinterpret_cast<TargetByGuid_t>(
static_cast<uintptr_t>(Offsets::FUN_TARGET_BY_GUID))(&guid);
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetTotemInfo", &Script_GetTotemInfo);
Game::Lua::RegisterGlobalFunction("GetTotemTimeLeft",
&Script_GetTotemTimeLeft);
Game::Lua::RegisterGlobalFunction("GetTotemDuration",
&Script_GetTotemDuration);
Game::Lua::RegisterGlobalFunction("TargetTotem", &Script_TargetTotem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
void OnPlayerSpellGo(uint32_t spellID) {
if (spellID == 0)
return;
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (rec == nullptr)
return;
const auto *effects = reinterpret_cast<const int32_t *>(rec + OFF_EFFECT);
const auto *misc = reinterpret_cast<const int32_t *>(rec + OFF_MISC_VALUE);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const int eff = effects[i];
if (eff < kEffectSummonTotemSlot1 || eff > kEffectSummonTotemSlot4)
continue;
const int idx = eff - kEffectSummonTotemSlot1; // 0..3 (Fire..Air)
Slot &t = g_slots[idx];
t.active = true;
t.seen = false;
t.spellID = spellID;
t.entry = static_cast<uint32_t>(misc[i]);
t.startMs = NowMs();
t.durationMs = SpellDurationMs(rec);
MarkDirty(idx); // totem dropped → PLAYER_TOTEM_UPDATE (fired next tick)
// The cast spell IS a totem of this element, so it names the slot's
// tool item directly — capture it (covers the slot even before the
// spellbook scan runs).
if (g_toolItem[idx] == 0)
g_toolItem[idx] = TotemToolItem(rec);
return; // one totem-slot effect per spell
}
}
} // namespace Totem::Tracker
+31
View File
@@ -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>
// Client-side totem-slot tracker backing `GetTotemInfo(slot)`. Vanilla
// 1.12 has no totem-bar / PLAYER_TOTEM_UPDATE machinery (all TBC
// additions), so we track the four element slots ourselves. See
// Tracker.cpp for the design.
namespace Totem::Tracker {
// Notify of a spell the LOCAL PLAYER just cast (from Aura::Source's
// SpellGo hook — the totem summon doesn't apply an aura, so it must be
// fed before that hook's aura gate). If `spellID` is a totem summon
// (`Spell.dbc` effect 87-90 = SUMMON_TOTEM_SLOT1..4), it's recorded in the
// matching element slot; otherwise ignored.
void OnPlayerSpellGo(uint32_t spellID);
} // namespace Totem::Tracker
+63
View File
@@ -0,0 +1,63 @@
// 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/>.
// Turtle WoW custom bag families. Turtle added four cooking/gathering bag
// families as `ItemBagFamily.dbc` rows 10–13 (Meat, Fish, Leather,
// Mining) — beyond retail vanilla, which stops at Keys (9). Turtle's own
// bags normally carry `m_bagFamily` directly, so this mapping is a safety
// net for any custom container shipped with an empty field; it plugs into
// the stock `Item::BagFamily` resolver chain rather than polluting the
// stock table with non-vanilla data.
//
// Subclass → raw family ID cross-referenced by name between
// ItemSubClass.dbc (class 1, Container) and ItemBagFamily.dbc. NOTE: the
// resulting bitmasks (`1 << (id-1)`) land on `0x200`–`0x1000`, which
// collide with retail's Gem/Mining bag bits — see docs/API.md's
// GetItemFamily family table for the caveat. The values stay
// self-consistent within Turtle (a Meat Bag and the meat it holds both
// report `0x200`).
//
// This file is the home for Turtle-only custom content that layers onto a
// stock engine surface; add further custom-data modules alongside it.
#include "item/BagFamily.h"
#include "turtle/Detect.h"
#include <cstdint>
namespace Turtle::BagFamily {
namespace {
// Turtle custom Container-class subclasses → `ItemBagFamily.dbc` raw IDs.
// Stock subclasses (Soul/Herb/Enchanting/Engineering) are handled by
// `Item::BagFamily`'s own table and never reach here. Gated on Turtle
// detection so the custom mapping can't misfire on a stock client (where
// these subclasses don't exist as containers anyway).
uint32_t Resolve(uint32_t itemClass, uint32_t subclass) {
if (!Turtle::Detected() || itemClass != Item::BagFamily::kItemClassContainer)
return 0;
switch (subclass) {
case 6: return 13; // Mining Bag → Mining Supplies
case 7: return 12; // Leatherworking Bag → Leather
case 8: return 10; // Meat Bag → Meat
case 9: return 11; // Fish Bag → Fish
default: return 0;
}
}
const Item::BagFamily::AutoResolver _register{&Resolve};
} // namespace
} // namespace Turtle::BagFamily
+66
View File
@@ -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.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Turtle WoW reworked Rip — known combo-finisher durations for a spell
// whose client DBC row is missing. All six Rip ranks point at
// SpellDuration row 87, which Turtle added on the SERVER only
// ({8000, 0, 18000} — live data shows 10/12/14/16/18s at 1..5 CP, i.e.
// 8s + 2s per combo point); the client patch never shipped the row (the
// client table jumps 86 → 105), so the index dangles. Verified the only
// dangling duration index in the entire client Spell.dbc.
//
// This supplies the values through `Aura::ComboDuration`'s dangling-row
// resolver so the Turtle-specific data lives here, not in the stock
// module. Unlike the other `src/turtle/` modules it does NOT gate on
// `Turtle::Detected()`: the activation gate is the dangling-row condition
// in `Aura::ComboDuration` (a client-data bug unique to this Rip), which
// is the deliberate design — gate on the bug, not the realm. On a stock
// client Rip resolves to a valid row and this resolver is never consulted;
// if Turtle ever ships the missing client row, the DBC takes over and this
// goes dormant automatically.
#include "aura/ComboDuration.h"
#include <cstdint>
namespace Turtle::ComboDuration {
namespace {
struct Known {
uint32_t spellId;
int32_t baseMs;
int32_t maxMs;
};
constexpr Known kRip[] = {
{1079, 8000, 18000}, {9492, 8000, 18000}, {9493, 8000, 18000},
{9752, 8000, 18000}, {9894, 8000, 18000}, {9896, 8000, 18000},
};
bool Resolve(uint32_t spellId, int32_t *baseMs, int32_t *maxMs) {
for (const auto &k : kRip) {
if (k.spellId == spellId) {
*baseMs = k.baseMs;
*maxMs = k.maxMs;
return true;
}
}
return false;
}
const Aura::ComboDuration::AutoDanglingResolver _register{&Resolve};
} // namespace
} // namespace Turtle::ComboDuration
+69
View File
@@ -0,0 +1,69 @@
// 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 "turtle/Detect.h"
#include "Game.h"
#include <cstring>
namespace Turtle {
namespace {
// Latched once we've read the global as a string — Turtle never un-sets
// it, so a true result is permanent. A false read is NOT cached: FrameXML
// may not have run yet, so we re-probe on each call until it lands.
bool g_detected = false;
char g_version[32] = {0};
void Probe() {
if (g_detected)
return;
void *L = Game::Lua::State();
if (L == nullptr)
return;
// Read `_G["TURTLE_WOW_VERSION"]` without disturbing the caller's
// stack. RawGet avoids any (nonexistent, but free-to-skip) metatable
// side effects on the globals table.
const int top = Game::Lua::GetTop(L);
Game::Lua::PushString(L, "TURTLE_WOW_VERSION");
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::IsString(L, -1)) {
const char *v = Game::Lua::ToString(L, -1);
if (v != nullptr) {
const size_t n = std::strlen(v);
const size_t copy =
(n < sizeof(g_version) - 1) ? n : sizeof(g_version) - 1;
std::memcpy(g_version, v, copy);
g_version[copy] = '\0';
}
g_detected = true;
}
Game::Lua::SetTop(L, top);
}
} // namespace
bool Detected() {
Probe();
return g_detected;
}
const char *Version() {
Probe();
return g_detected ? g_version : nullptr;
}
} // namespace Turtle
+37
View File
@@ -0,0 +1,37 @@
// 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
// Turtle WoW client detection — the gate for every module under
// `src/turtle/`. Turtle's FrameXML sets a `TURTLE_WOW_VERSION` global (see
// `FrameXML/Globals.lua`, e.g. `"1.18.1"`); its presence is the
// authoritative "this is the Turtle client" signal — no realm-list
// sniffing (which conflates "connected to Turtle" with "running the
// Turtle client" and was rejected for the Rip combo-duration work), no
// binary fingerprinting.
namespace Turtle {
// True iff running on a Turtle WoW client. Reads the `TURTLE_WOW_VERSION`
// global and LATCHES true once seen. Safe to call any time: it returns
// false until the in-game Lua state exists AND FrameXML's Globals.lua has
// run (so it can read false very early in boot / on the glue screen), then
// flips true and stays true. Never caches a false result, so an early
// probe can't wrongly pin a Turtle client as non-Turtle.
bool Detected();
// The Turtle client version string (e.g. `"1.18.1"`), or nullptr when not
// Turtle / not yet detected. Points at an internally-cached copy.
const char *Version();
} // namespace Turtle
+44
View File
@@ -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.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Turtle WoW custom healing auras. Aura 199
// (MOD_SPELL_HEALING_OF_ARMOR_PERCENT, "Ironclad") adds
// `Armor × amount / 100` to the player's spell healing bonus — not a stock
// vanilla aura index. Plugs into `Spell::HealingAura`'s resolver chain so
// the Turtle-specific aura semantic lives here, not in the stock
// GetSpellBonusHealing decoder. Gated on Turtle detection (the aura index
// is a generic slot number that could mean something else on another
// server).
#include "spell/HealingAura.h"
#include "turtle/Detect.h"
#include <cstdint>
namespace Turtle::HealingAura {
namespace {
constexpr int kAuraModHealingOfArmorPercent = 199; // × Armor (Ironclad)
long Resolve(int auraIndex, long amount, int32_t /*spirit*/, int32_t armor) {
if (!Turtle::Detected() || auraIndex != kAuraModHealingOfArmorPercent)
return 0;
return static_cast<long>(armor) * amount / 100;
}
const Spell::HealingAura::AutoResolver _register{&Resolve};
} // namespace
} // namespace Turtle::HealingAura
+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
+64
View File
@@ -0,0 +1,64 @@
// 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/>.
// `ClosestUnitPosition(creatureID)` → `xPos, yPos, distance` — the world
// position of the nearest creature with the given NPC (creature-template)
// ID, and its distance from the player in yards. Returns nothing when no
// matching creature is found (the modern `MayReturnNothing` contract).
//
// Semantic note vs. retail: modern WoW's `ClosestUnitPosition` reads a
// static client-side spawn database and only works for starting-zone mobs
// (a tutorial/help helper). Vanilla 1.12 has no such database, so we
// implement the useful, 1.12-native reading: the closest creature of that
// entry that is CURRENTLY VISIBLE (in the client's object manager /
// broadcast window). The shared scan lives in Object::ClosestByEntry.
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "object/ClosestByEntry.h"
#include <cmath>
#include <cstdint>
namespace Unit::ClosestPosition {
namespace {
int __fastcall Script_ClosestUnitPosition(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: ClosestUnitPosition(creatureID)");
return 0;
}
const auto entry = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const Object::ClosestByEntry::Result r = Object::ClosestByEntry::Find(
Offsets::TYPEMASK_UNIT, Guid::Type::Creature, entry);
if (!r.found)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(r.x));
Game::Lua::PushNumber(L, static_cast<double>(r.y));
Game::Lua::PushNumber(L, std::sqrt(static_cast<double>(r.distSq)));
return 3;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("ClosestUnitPosition",
&Script_ClosestUnitPosition);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::ClosestPosition
+22
View File
@@ -163,6 +163,27 @@ int __fastcall Script_UnitIsOtherPlayersPet(void *L) {
return 1;
}
// `UnitOwnerGUID(unit)` — the GUID string of the unit's owner (the
// summoner of a pet/guardian/totem, or the charmer of a charmed unit),
// formatted `"0x…"`. Reuses the same owner field the pet predicates use
// (CharmedBy, else CreatedBy). Returns `nil` for an unresolved unit and
// for anything with no owner (players, world creatures).
int __fastcall Script_UnitOwnerGUID(void *L) {
const uint8_t *unit = ResolveUnit(L);
if (unit == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
const uint64_t owner = OwnerGuid(unit);
if (owner == 0) {
Game::Lua::PushNil(L);
return 1;
}
char buf[Guid::STRING_SIZE];
Game::Lua::PushString(L, Guid::FormatAsString(owner, buf, sizeof buf));
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
@@ -170,6 +191,7 @@ static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitIsPet", &Script_UnitIsPet);
Game::Lua::RegisterGlobalFunction("UnitIsOtherPlayersPet",
&Script_UnitIsOtherPlayersPet);
Game::Lua::RegisterGlobalFunction("UnitOwnerGUID", &Script_UnitOwnerGUID);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+69 -16
View File
@@ -13,20 +13,33 @@
// `GetPosition` virtual (vtable slot 5) `CheckInteractDistance` uses:
//
// `UnitInRange(unit) → (inRange, checkedRange)` — 40-yard healing-
// range check (fixed threshold on the squared distance).
// range check. Reach-aware: matches the engine's own spell-range
// formula (see below), so a 40-yard heal registers in-range against
// a target whose *center* is slightly past 40 yards but whose body
// is within reach.
// `UnitDistanceSquared(unit) → (distanceSquared, checkedPosition)` —
// the raw squared world distance player→unit. Squared because
// nearly every consumer compares against a threshold (`distSq <=
// range*range`) or ranks by nearest, neither of which needs the
// sqrt; that's why retail exposes only the squared form (added
// 5.0.4). Center-to-center — NOT reach-aware (that's UnitXP_SP3's
// niche); equivalent to computing it from SuperWoW `UnitPosition`,
// but self-contained (reads the position in C, no sibling-DLL
// dependency, no clash with SuperWoW's own `UnitPosition`).
// 5.0.4). Center-to-center — this one is NOT reach-adjusted (retail's
// `UnitDistanceSquared` is also raw center distance); the reach
// correction lives only in `UnitInRange`.
//
// Reach-awareness: the engine's spell-range helper `FUN_006e3480` compares
// center distance against `baseRange + reachCaster + reachTarget`, where
// each unit's reach is the bounding-radius float at `[m_objectFields +
// 0x1F0]` (the same size factor the interact-distance and loot-range
// checks add). So the effective in-range distance for a 40-yard heal is
// `40 + playerReach + targetReach`. On big targets that's easily 2–3
// yards past the raw 40, which is why a pure center-to-center 40-yard cap
// reported out-of-range for a target the client could actually heal.
#include "Game.h"
#include "unit/Position.h"
#include "Offsets.h"
#include <cstdint>
#include <cstring>
namespace Unit::Range {
@@ -34,18 +47,44 @@ namespace Unit::Range {
namespace {
constexpr float kRangeYards = 40.0f;
constexpr float kRangeYardsSq = kRangeYards * kRangeYards;
// Squared world distance from the player to `token`'s unit. Returns true
// and writes *outSq on success; false when either position is unavailable
// (unresolved / absent token, or an object with no known position yet —
// e.g. a party member outside the client's sync range). This false is
// exactly the `checkedPosition = false` case retail flags.
// Reads a unit's bounding radius (the engine's per-unit range "size
// factor") from `[m_objectFields + 0x1F0]`. Returns 0 for a null object
// or a unit whose descriptor isn't populated yet — a safe fallback that
// degrades to the plain base-range check. Same field the loot/interact
// range checks (loot/Nearby.cpp) read.
float BoundingRadius(void *obj) {
if (obj == nullptr)
return 0.0f;
auto *fields = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(obj) + Offsets::OFF_UNIT_DESCRIPTOR);
if (fields == nullptr)
return 0.0f;
return *reinterpret_cast<const float *>(
fields + Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
}
// Resolves `token` once and reads both its world position and bounding
// radius. Returns false when the position is unavailable (unresolved /
// absent token, or an object with no known position yet — e.g. a party
// member outside the client's sync range). This false is exactly the
// `checkedPosition = false` case retail flags.
bool ReadPosAndReach(const char *token, float pos[3], float *reach) {
void *obj = Unit::Position::ResolveToken(token);
if (!Unit::Position::Read(obj, pos))
return false;
*reach = BoundingRadius(obj);
return true;
}
// Squared world distance from the player to `token`'s unit (raw,
// center-to-center; reach is applied by the caller when needed).
bool PlayerToUnitDistSq(const char *token, float *outSq) {
float unitPos[3] = {};
float playerPos[3] = {};
if (!Unit::Position::ReadToken(token, unitPos) ||
!Unit::Position::ReadToken("player", playerPos))
float reach = 0.0f;
if (!ReadPosAndReach(token, unitPos, &reach) ||
!ReadPosAndReach("player", playerPos, &reach))
return false;
const float dx = unitPos[0] - playerPos[0];
const float dy = unitPos[1] - playerPos[1];
@@ -72,14 +111,28 @@ int __fastcall Script_UnitInRange(void *L) {
return 2;
}
float distSq = 0.0f;
if (!PlayerToUnitDistSq(token, &distSq)) {
float unitPos[3] = {};
float playerPos[3] = {};
float unitReach = 0.0f;
float playerReach = 0.0f;
if (!ReadPosAndReach(token, unitPos, &unitReach) ||
!ReadPosAndReach("player", playerPos, &playerReach)) {
Game::Lua::PushBool(L, false);
Game::Lua::PushBool(L, false);
return 2;
}
Game::Lua::PushBool(L, distSq <= kRangeYardsSq);
const float dx = unitPos[0] - playerPos[0];
const float dy = unitPos[1] - playerPos[1];
const float dz = unitPos[2] - playerPos[2];
const float distSq = dx * dx + dy * dy + dz * dz;
// Effective range = base + both units' reach, mirroring the engine's
// spell-range formula (FUN_006e3480). Without the reach terms a target
// whose center sits just past 40 yards but whose body is within reach
// reports out-of-range even though a 40-yard heal would land.
const float effRange = kRangeYards + playerReach + unitReach;
Game::Lua::PushBool(L, distSq <= effRange * effRange);
Game::Lua::PushBool(L, true);
return 2;
}