Compare commits

...
Author SHA1 Message Date
Brues d021ae3e3d docs(faction): describe the reputation functions
Descriptors for GetFactionIDByIndex, GetFactionInfoByID,
GetFactionParentID, the 17 undocumented C_Reputation functions, the
FactionData structure, and the FACTION_STANDING_CHANGED event.
C_Reputation.IsFactionInactive and SetFactionInactive mirror stock
globals, but no Blizzard client has them under C_Reputation, so they
are marked as ClassicAPI extensions.
2026-10-30 02:34:17 -05:00
Brues f4862dc817 docs(equipmentset): describe the C_EquipmentSet functions
Descriptors for the 18 undocumented C_EquipmentSet functions,
GameTooltip:SetEquipmentSet, and the EQUIPMENT_SETS_CHANGED,
EQUIPMENT_SWAP_PENDING and EQUIPMENT_SWAP_FINISHED events. They
describe what the functions return here, which differs from retail in
places: an icon path, not a file ID; numMissing, not numLost;
GetIgnoredSlots lists slot numbers.
2026-10-30 02:34:17 -05:00
Brues 3a6f53f919 docs(aura): describe the C_UnitAuras functions
Descriptors for the 16 undocumented C_UnitAuras functions, plus the
AuraData structure that GetAuraDataByAuraInstanceID already named but
nothing defined. The four positional getters share the Classic Era
UnitAura return list.
2026-10-30 02:34:17 -05:00
Brues 19b299d3df docs(container): describe the C_Container functions
Descriptors for the 17 undocumented C_Container functions and the
ItemQuestInfo structure. SwapItems, MoveItem, AutoStoreItem,
GetContainerItemCharges, GetContainerItemRepairCost and
IsContainerItemOpenable are not in retail's API and are marked as
ClassicAPI extensions.
2026-10-30 02:34:17 -05:00
Brues 77b304ae68 feat(smf): RemoveMessagesByPredicate
ScrollingMessageFrame:RemoveMessagesByPredicate(predicate), retail's: the
predicate is called with each message's GetMessageInfo values, oldest
first, and the messages it returns true for are removed; the rest keep
their order. It covers 4.3.4's RemoveMessagesByAccessID / ByExtraData,
whose int fields the retail-shaped records here do not have.

1.12's ring has no compaction of its own; the removal is 4.3.4's
RemoveLines loop over it: every kept record moves forward in ring order to
the first free slot (freeing the text it lands on, nulling the one it
left), the position moves back by the removed count, and the
extra-argument tables move with their records. Then the engine's own
layout (0x00788750) from the record at the kept scroll offset, clamped to
the oldest, and its re-arm (0x00788B80) - a layout at the bottom keeps the
fade state, so 4.3.4 re-arms after - or its Clear (0x007882B0) when
nothing is kept.

The predicate runs for every message before anything moves, through the
engine's error handler; a predicate that errors keeps that message, as
retail's safesecurecall does. GetMessageInfo shares its record lookup and
value push with it.
2026-10-30 02:14:08 -05:00
Brues c965e49117 feat(smf): BackFillMessage
ScrollingMessageFrame:BackFillMessage(text, r, g, b, ...), retail's: the
message becomes the oldest, for rebuilding a frame from history newest to
oldest (Blizzard_CombatLog's refilter adds each entry with it).

The ring's writer 0x00788150 has one slot rule: the one after the newest.
BackFillMessage writes the slot 4.3.4's AddMessage(..., addToTop) picks —
the free one before the oldest, (pos - count + 1) mod max once the count
has grown — and fills the record as the writer does: a Storm copy of the
text, AddMessage's colour (clamped, x255 + 0.5 truncated, alpha 0xFF; the
font's without r, g, b), shown, the frame's two times, id 0, and the extra
arguments kept for GetMessageInfo. Nothing already in the ring moves, so a
scrolled-up frame keeps its view; at the bottom the lines are laid out
again from the newest, as the writer does.

A frame at its line limit drops the message, as retail's CircularBuffer
PushBack does ("Won't overwrite front"); 4.3.4 overwrites the oldest slot
there, which Blizzard_CombatLog never reaches.
2026-10-30 01:45:47 -05:00
Brues ead52e79ce fix(editbox): caret, selection and click rows follow tall inline icons
A multi-line edit box places its caret, selection pieces and a click's
cursor by rows of one height (font height + spacing, pixel-aligned), while
the render moves a line holding an icon taller than the font, and every
line after it, down by the overflow, and the box grows to match. Below the
first tall-icon line the caret and highlight sat above their text, and
OnCursorChanged reported that y, so ScrollingEditBoxMixin's scroll-into-
view stopped short (pfUI chat copy with faction emblems).

4.3.4's CSimpleEditBox has the same fault: UpdateVisibleCursor,
UpdateHighlightArea and ConvertCoordinateToIndex are icon-blind too. So
this goes past 4.3.4, as editbox/CharClass does. EditBox::IconRows co-hooks
the three placers and gives each the render's move, from the new
Text::InlineTexture::LineIconShift over the box's own line table: the
click's y is mapped onto the uniform row before the engine reads it; the
selection piece and caret are re-anchored after, with the engine's own x;
the caret's OnCursorChanged is muted for the original call and fired once
with the corrected y through the engine's forwarder, with the same packing
PlaceCaret uses (verified in the disassembly and the binary's constants).

Also fixes the "caret positioner" misnomer for 0x77DE70 (the selection-
piece placer) in Offsets.h and the docs.
2026-10-30 01:15:01 -05:00
Brues 7cb6cda101 feat(smf): TOP insert mode
SetInsertMode(SCROLLING_MESSAGE_FRAME_INSERT_MODE_TOP) now shows the
newest message at the top, older ones below it, as retail's mixin does.

1.12's line layout anchors each line once, when it makes it, through one
SetPoint call (0x00788975). That call is redirected: lines of a TOP frame
take the TOP anchors, every other line keeps the engine's own arguments.
The layout remakes the lines when the frame resizes, so the redirect
covers that too. A mode change re-anchors the existing lines with the
layout's own steps and refreshes the display.

Insert mode moves out of Scroll.cpp into InsertMode.cpp.
2026-10-30 00:20:19 -05:00
Brues 5ab497f2c7 feat(smf): scroll offset, insert mode, display-refreshed callbacks
Retail's ScrollingMessageFrame SetScrollOffset, GetScrollOffset,
GetMaxScrollRange, ScrollByAmount, SetInsertMode / GetInsertMode with the
SCROLLING_MESSAGE_FRAME_INSERT_MODE_* globals, and
AddOnDisplayRefreshedCallback, which ScrollUtil's
InitScrollingMessageFrameWithScrollBar calls.

The offset is the engine's own: SetScrollFromBottom's body (0x00788700)
is 4.3.4's SetScrollOffset step for step, and GetCurrentScroll is
GetScrollOffset under retail's name. The setters clamp to
0..GetNumMessages()-1 first, as retail does; the engine wraps. 1.12's
layout stacks lines bottom-up only, so the insert mode is BOTTOM and
SetInsertMode raises an error for anything else.

The callbacks run after the frame's own update (0x00788460, OnUpdate then
the fader), where retail's OnPostUpdate runs them, for a frame the line
layout, AddMessage or ScrollByAmount marked. The layout hook moves out of
Text::InlineTexture into ScrollingMessageFrame::Layout, which owns it and
fans out to both.
2026-10-29 23:56:24 -05:00
Brues 205d1fd43b feat(smf): ScrollingMessageFrame GetMessageInfo
Retail's smf:GetMessageInfo(index): text, r, g, b, then every argument
AddMessage took after the colour. Index 1 is the oldest message, and an
index outside 1..GetNumMessages() returns nothing.

The text and colour come from the engine's record in the frame's message
ring, so UpdateColorByID's changes read back. The record keeps only an
integer id after the colour, so AddMessage is registered again over the
engine's: Storm's hash links a new method node at the head of its bucket
and the lookup takes the first match. It runs the engine's AddMessage,
then keeps the extra arguments for the record just written, in a
registry table reused per record.
2026-10-29 23:43:45 -05:00
Brues 950c9deb76 feat(death): C_DeathInfo self-resurrect options, SELF_RES_SPELL_CHANGED
C_DeathInfo.GetSelfResurrectOptions, C_DeathInfo.UseSelfResurrectOption
and Enum.SelfResurrectOptionType over what 1.12 already serves through
HasSoulstone and UseSoulstone. Both pick one option the same way:
PLAYER_SELF_RES_SPELL when the server set it, else the first carried
item whose on-use spell is a self-resurrect. The list holds that option
under HasSoulstone's alive gate, and using it calls the engine's
UseSoulstone. Nothing in 1.12 limits a self-resurrection, so canUse is
true, isLimited false and priority 0.

SELF_RES_SPELL_CHANGED is 4.3.4's watcher on PLAYER_SELF_RES_SPELL from
SetActiveMirrorHandlers. 1.12 watches no such field, so the watcher joins
CGPlayer_C::AddActiveWatchers and leaves before its removal, the lifetime
of the engine's own player watchers.
2026-10-29 20:37:40 -05:00
Brues e7ccc11309 feat(unit): UnitHasIncomingResurrection, INCOMING_RESURRECT_CHANGED
4.3.4's CGIncomingResurrection. SMSG_SPELL_START adds a resurrect cast
under its object target, a unit or a corpse. SMSG_SPELL_GO, the failure
packets and ClearCastingSpell (through Spell::Cast, Turtle's only
interrupt signal) end it. A cast whose caster leaves view never reports
its end, so it ends on the next world tick. Each change fires the event
for every token naming the target, or the corpse's owner. 1.12's class
resurrections carry effect 113, not 4.3.4's 18, so both count.

Object::Corpse::ByOwner, lifted from spell/Range.cpp, now skips bones as
4.3.4's ClntObjMgrFindCorpseByOwner does. Turtle's bones keep their
owner, so old bones hid a ghost's live corpse, from this API and from
the range check's ghost redirect.

HasEffect moves from CombatLog to Spell::Lookup, and the SpellCastTargets
object mask 0x8A02 becomes one Offsets.h constant.
2026-10-29 19:21:47 -05:00
Brues bab31b5f7b docs(unit): bound the GetShapeshiftForm example to the bar
GetShapeshiftForm returns the bar count + 1 for a form that is not on
the stance bar, as 3.3.5 and 4.3.4 do. The example passed that index to
GetShapeshiftFormInfo, which returns nils for it.
2026-10-28 23:31:49 -05:00
Brues e0e24bab34 feat(macro): SetMacroDisplay range unit
C_Macro.SetMacroDisplay(macroSlot, value [, unit]) takes the unit the
button's range check uses, so a publisher's [@unit] reaches the range
check as SetMacroSpell's target does. The token stays a token: each
lookup resolves it again, so the range follows a new mouseover, focus or
target with no republish. A token that names no unit leaves the target
in charge.

The engine's resolver vets the unit at publish and raises its "Unknown
unit name" error for anything else, as UnitExists does. It tells a
token's family from its spelling alone, so a token that passes once
never raises at a later lookup. The unit is ignored for false and for an
empty value, and a publish that changes only the unit updates it.
2026-10-28 23:19:36 -05:00
Brues a46d823b01 feat(quest): completed quests on Turtle-lineage servers
QueryQuestsCompleted, GetQuestsCompleted, C_QuestLog.IsQuestFlaggedCompleted,
C_QuestLog.GetAllCompletedQuestIDs and QUEST_QUERY_COMPLETE, on Turtle clients
only. The server answers `.queststatuses` with TWQUEST guild addon messages
from the player, split at 2000 characters; the shorter last message is the
end of the list, so no timers are needed.

The DLL sends the request itself on SMSG_LOGIN_VERIFY_WORLD (the earliest
request the server answers), after each turn-in, and from
QueryQuestsCompleted. It reads every answer at the packet funnel, checks that
the sender is the player, replaces the list and fires QUEST_QUERY_COMPLETE.
A turned-in quest goes into the list at once; the server's next answer takes
repeatable quests out again. The list lives through /reload.

Net::OutPacket replaces the two hand-built CDataStore senders (item cache
resend, SetPVP). Replaces the closed Lua collector of PR #86.
2026-10-28 21:42:15 -05:00
Brues 38337c841d feat(container): SetItemSearch, IsContainerFiltered
Port of 4.3.4's bag search (CGContainerInfo): C_Container.SetItemSearch,
C_Container.IsContainerFiltered and INVENTORY_SEARCH_UPDATE. An item
matches on its equipment sets' names, its suffixed name, its subclass
short name or its equip slot name, case- and accent-folded per locale.
The keyring is searched; non-ASCII search text is folded whole.

C_Container.GetContainerItemInfo now reports isFiltered and takes an
optional table to fill and return, so bag addons can read every slot
without a new table per call. Item::Location::ResolveBagArgs resolves
the caller's own bag/slot args without wiping the stack.
2026-10-28 20:56:52 -05:00
Brues 5a0b4f2e31 feat: GetTradeSkillDescription, GetTrainerServiceItemLink
Both mirror 3.3.5 (0x005DBE10, 0x00596290).

GetTradeSkillDescription: nil unless the row's recipe has a description
in the client's locale, then 1.12's spell-text parser with the arguments
GetCraftDescription passes. That call moves to Spell::Description::Expand,
shared with C_Spell.GetSpellDescription.

GetTrainerServiceItemLink: a 1.12 server sends the trainer spell that
teaches the recipe, not the recipe. Follow its first LEARN_SPELL or
LEARN_PET_SPELL effect as 1.12's GetTrainerServiceIcon (0x004D8F50) does,
then 3.3.5's gate: tradeskill trainer, effect 0 CREATE_ITEM, item cached.
3.3.5's other create-item effects are dropped: 1.12's 59 is
OPEN_LOCK_ITEM and 157 does not exist.
2026-10-28 20:11:12 -05:00
Brues 7d9dafbfcf feat(gossip): GetNumGossipOptions
3.3.5 pushes m_gossipOptionsCount; 1.12 keeps no count. Count the slots
1.12's Script_GetGossipOptions (0x4E28D0) pushes: every slot with a
non-negative index, no session check, so the count matches the list.
2026-10-28 18:53:12 -05:00
Brues 79d4b7f44d feat(faction): ExpandAllFactionHeaders, CollapseAllFactionHeaders
3.3.5 and 4.3.4 call CGReputationInfo::CollapseHeader(m_numFactions,
collapse); an index at the entry count sets every header. 1.12's
CollapseHeader (0x4D6400) has the same branch, so both make that call
with 1.12's entry count.
2026-10-28 18:49:32 -05:00
Brues 9b07ad15df feat(chat): IsChatAFK, IsChatDND
IsChatAFK reads 1.12's chat AFK mirror (0xB6E5CC), 4.3.4's CGChat::m_afk,
with the same writers: SetAfk, ClearAfk, the PLAYER_FLAGS change handler
and world entry. 1.12 keeps no DND mirror; its /dnd branch tests the
PLAYER_FLAGS DND bit, so IsChatDND reads that bit.
2026-10-28 18:44:19 -05:00
Brues 36d55ab305 feat(player): IsLoggingOut, IsPlayerInWorld
IsLoggingOut reads the current connection's logout-pending byte through
the engine's own ClientServices::GetCurrent / IsLogoutPending pair, as
4.3.4's CharacterLoggingOut does. IsPlayerInWorld reads VAR_IN_WORLD,
1.12's CGGameUI::s_inWorld. Both are in-game only, as in 4.3.4.
2026-10-28 18:34:20 -05:00
Brues ebe9b9b6de feat(friendlist): AddOrRemoveFriend
4.3.4's Script_AddOrRemoveFriend: removes the friend whose name matches
without case (1.12's RemoveFriendByName scan, then the engine's
SendRemoveFriend), else adds through FriendList::AddFriend. The note goes to
the client-side store once the server adds the friend: it waits by name
until FriendList::SetFriendName names the slot, via redirects of that
function's two call sites. The add reply can't be paired with the request,
since tortoise-wow sends none for an unknown name. Also lists
CanCancelAuction in the README.
2026-10-28 18:21:41 -05:00
Brues 4df29722d2 feat(auction): CanCancelAuction
4.3.4's Script_CanCancelAuction: nothing without an auctioneer, nil for an
owner-list index with no row, else 1. Gates mirror 1.12's own CancelAuction
(0x004D0260); 1.12 owner records have no sold flag, and the cancel-fee money
check stays in CancelAuction as in 4.3.4.
2026-10-28 18:10:08 -05:00
Brues 086b9f9fbb feat(range): 4.3.4 target gate for IsSpellInRange / IsItemInRange
C_Spell.IsSpellInRange and C_Item.IsItemInRange now run through
Spell::Range::CastVsUnit, a port of 4.3.4's Spell_C_RangeCheckSelected on
1.12's own target gate (CheckSpellTargetRange 0x6E4440, mirrored branch
for branch since it folds "can't target" into "in range"):

- nil for a unit the spell cannot be cast at (reaction, creature type,
  dead/alive state)
- spells aimed at the pet (implicit target 5) measure to the pet
- a resurrect (target word 0x8000) on a ghost measures to its corpse
- pet-book spells use the pet as caster; names take the book the
  engine's name resolver matched

UnitInRange measures from the active mover (VAR_ACTIVE_MOVER_GUID), as
4.3.4 does, so a controlled unit is the origin.
2026-10-28 16:20:00 -05:00
Brues bc2481395d feat(pvp): SetPVP, GetPVPDesired, GetPVPTimer, IsPVPTimerRunning
SetPVP / C_PvP.SetPVP send CMSG_TOGGLE_PVP with the state byte (3.3.5
Script_SetPVP); tortoise-wow and vmangos both honor it. The timer mirrors
3.3.5's GetPVPToggleTimer, with "running" derived client-side since 1.12
servers never set PLAYER_FLAGS 0x40000: armed by the engine's
ERR_PVP_TOGGLE_OFF notice while flagged, disarmed by the toggle-on notice,
the active player's UNIT_FLAG_PVP drop, or glue boot. Three call-site
redirects, no MinHooks. PVP_TIMER_UPDATE("player") fires on start/stop.
2026-10-28 15:47:39 -05:00
Brues daded912c2 feat(input): F13-F24 key support
The window procedure's key translator dropped VK_F13..VK_F24, so the
keys never reached bindings or frame scripts. Translate them to codes
0x2F4..0x2FF (the free range below F1; codes must stay under the
0x30E-entry key-taker array) and name them in the engine's three
code-to-name decoders by retargeting each one's UNKNOWN branch.
2026-10-28 15:26:25 -05:00
Brues a0c5c8a30c feat(input): GetMouseButtonName
Names come from the engine's own click-name literals, so an ID outside
1..5 is "UNKNOWN", as 1.12's OnClick names it.
2026-10-28 15:13:01 -05:00
Brues e26f8fb49e feat: ConsoleAddMessage, CursorHasMacro, GetMacroBody
GetMacroBody shares the index-or-name macro argument with SetMacroSpell /
SetMacroItem through the new Macro::Arg::Slot.
2026-10-28 15:07:29 -05:00
Brues da9e9b6f55 feat(spell): IsAttackSpell, IsAutoRepeatSpell, IsConsumableSpell 2026-10-28 15:02:02 -05:00
Brues a5e8a6f5b6 feat(spell): GetSpellBaseCooldown 2026-10-28 14:50:18 -05:00
Brues f191c53c2a feat(unit): CanBeRaidTarget 2026-10-28 14:43:38 -05:00
Brues 797fea90ed fix(spell): Resolve the has-range gate with a null target, as ActionHasRange does
PlayerVsUnit passed the real target to GetMinMaxRange (0x6E3480), which folds both units' combat reach into every non-melee range row. Self-range spells (Hearthstone, totems, professions) came back with max ~3yd, passed the gate, and IsSpellInRange / IsItemInRange answered false instead of nil past melee distance. The engine's ActionSlotHasRange (via ActionHasRange 0x4E75D0) always resolves with a null target; EngineHasRange now does the same.
2026-10-28 14:32:01 -05:00
Brues 026fe20a89 feat(pet): IsAtStableMaster 2026-10-28 14:25:10 -05:00
Brues 529bacd0cb feat(pet): IsPetAttackAction 2026-10-28 14:19:33 -05:00
Brues c16c3a90dc feat(merchant): GetBuybackItemLink; buyback getters skip the vendor gate
GetBuybackItemLink(slot) builds the link with the engine's own item
link builder. It and C_MerchantFrame.GetBuybackItemID share one
buyback-slot resolver with no vendor-GUID check, as 1.12's
GetBuybackItemInfo (0x004FB310) and 4.3.4's GetBuybackItemLink do.
2026-10-28 14:12:32 -05:00
Brues ab829e98c0 feat(action): IsItemAction, HasBonusActionBar 2026-10-28 14:03:58 -05:00
Brues 602840419b feat(quest): GetQuestLogCompletionText
Returns the quest objectives with $N-style tokens expanded, the branch
4.3.4 takes when a quest has no completion text; the 1.12 query response
carries none. Takes a log index or the selected quest.

The cache field at +0x129C is the quest's EndText, not completion text:
rename OFF_COMPLETION_TEXT to OFF_END_TEXT and GetQuestDetails' key to
endText. Quest::Cache::ExpandText now owns the token-substitution call
that the quest hyperlink tooltip used to define locally.
2026-10-28 13:53:26 -05:00
Brues 40cd25315d feat(player): GetCritChance, GetRangedCritChance
Read PLAYER_CRIT_PERCENTAGE / PLAYER_RANGED_CRIT_PERCENTAGE from the
CGPlayer sub-struct, the same way the engine's GetDodgeChance reads its
neighbouring field.
2026-10-28 13:42:27 -05:00
Brues 984dc236ab feat(dressup): try on what a recipe makes
4.3.4's TryOnWithVisual swaps a recipe item (no inventory type, a spell
that teaches a CREATE_ITEM spell) for the item it makes; 1.12's worker
shows ERR_NOT_EQUIPPABLE instead. TryOnItem adds the swap in front of the
worker for DressUpModel:TryOn and, via a call redirect, for the worker's
item-cache callback, so Control-click on a pattern opens the dressing
room even before the pattern is cached. The recipe walk is shared with
IsDressableItem (Item::Dressable::RecipeItem).
2026-10-28 13:12:47 -05:00
Brues cdc3506cdd feat(item): IsEquippedItemType, IsCurrentItem, IsDressableItem, IsHelpfulItem, IsHarmfulItem
Ports of 4.3.4's item predicates. IsEquippedItemType runs the engine's
inventory search over the equipment slots by equip location, item class
or subclass name; the [equipped] macro condition now calls it instead of
a Lua rebuild. IsCurrentItem reads the in-flight cast item that 1.12's
IsCurrentAction checks. IsHelpfulItem / IsHarmfulItem classify the
on-use spell with the IsHelpfulSpell / IsHarmfulSpell classifier.

Shared: Item::Location::IDForArg (GetItemIDByName, was copied in three
files) and Item::Location::Walk over FUN_INVMGR_WALK.
2026-10-28 13:06:48 -05:00
Brues 6347bdd6c5 feat(expansion): GetExpansionLevel, GetAccountExpansionLevel, GetMaxPlayerLevel
Both expansion levels answer the shared kCurrentExpansionLevel (0), the
value LE_EXPANSION_LEVEL_CURRENT and GetClassicExpansionLevel give.
GetMaxPlayerLevel is a constant, as 4.3.4's is (85): 60, the cap 1.12's
own FrameXML keeps in MAX_PLAYER_LEVEL.
2026-10-28 12:49:57 -05:00
Brues ff4a9ffd5a feat(macro): SetMacroSpell, SetMacroItem, macro range unit
SetMacroSpell and SetMacroItem publish a spellbook spell or an item as
a macro's display, like 4.3.4: a name that resolves to nothing greys
the button, and the claim lasts until the macro's body is edited.

Each macro now keeps the unit its spell is aimed at (4.3.4's
MACRONODE::m_target): the target those functions name, or the [@unit]
of the clause a #showtooltip or /cast line matched. Action::Range
redirects IsActionInRange's call to the slot range function and, for a
macro slot with a unit, passes that GUID to the engine's range check
in place of the selection, as CGActionBar::HasValidTarget does.
2026-10-28 12:46:56 -05:00
Brues 9541dc2949 feat(macro): RunMacroText, GetRunningMacro, GetRunningMacroButton
RunMacroText copies the text into the body of a zeroed stack macro entry
and hands it to the engine's macro runner, as 4.3.4 does with a stack
MACRONODE. The runner reads only the body, so each line goes the
engine's way: EXECUTE_CHAT_LINE to MacroEditBox, with # lines skipped
and /stopmacro honored.

Macro::RunBody keeps the running entry and button per run, saved and
restored around nested runs. GetRunningMacro maps the entry's ID to its
index; GetRunningMacroButton returns the button. [button] now matches
the running macro's button as well as the clicked one.

Macro::Execute drops its Lua ChatEdit_ParseText path and the addon
RunMacro preference. The secure button's macro verb and the MACRO
binding run through the engine runner with the clicked button and
"LeftButton".
2026-10-28 12:28:29 -05:00
Brues 1340cc75c7 feat(chat): SendSystemMessage
Shows a local CHAT_MSG_SYSTEM line through the engine's chat line
function, called as the game-message display calls it for a chat-kind
message: chat type 10 and ten zero arguments.
2026-10-28 12:08:58 -05:00
Brues 9c50a90d34 feat(currency): GetCoinText
GetCoinText(amount [, separator]) and C_CurrencyInfo.GetCoinText: the
non-zero gold, silver and copper parts, each from its GOLD_AMOUNT /
SILVER_AMOUNT / COPPER_AMOUNT global string, joined by the separator
(default ", "). Zero is the empty string.

The three strings are backported to every embedded locale.
2026-10-28 12:03:24 -05:00
Brues 1942383494 feat(group): InviteUnit, UninviteUnit, PromoteToLeader
Group verbs that take a unit token or a name in one call. Each one
calls the engine's own group sender on the active player:

- InviteUnit(targetName): name only, with the ERR_INVITE_SELF check
  and the invite tutorial flag.
- UninviteUnit(name): a unit token resolves to a GUID; any other
  string is sent as a name.
- PromoteToLeader(name [, exactNameMatch]): a unit token resolves to
  a GUID; a name goes through the engine's by-name search in
  PromoteByName's mode.
2026-10-28 11:58:42 -05:00
Brues 0f37ddc48d feat(aura): CancelUnitBuff
CancelUnitBuff(unit, index [, filter]) cancels the player's own aura at
the given index. The index counts in FindNthSlot order (4.3.4's
GetSortedAuraSlot), so it matches the C_UnitAuras by-index getters; the
filter defaults to HELPFUL. The cancel gate is the engine's: the slot's
cancelable bit or the spell's AttributesEx 0x4.
2026-10-28 11:43:19 -05:00
Brues ac118d5305 feat(unit): GetShapeshiftForm
Returns the stance-bar index of the active form, walking the bar from the
last entry as 4.3.4's CGSpellBook::GetShapeshiftForm does: count + 1 for a
form not on the bar, 0 for requireForm with no form byte. Each index is
tested through the engine's Script_GetShapeshiftFormInfo, which holds
1.12's only full "bar spell is active" check.

The [stance] / [form] macro conditions now use GetShapeshiftForm(true),
as Blizzard's EvaluateStance does, so a paladin aura no longer counts as
a stance.
2026-10-28 11:35:26 -05:00
Brues cf645a30af feat(spell): CastSpellByID
Casts the exact spell ID from the player's or the pet's spellbook at the
current target, or at the unit an optional token names, through the
existing exact-ID dispatch. Returns true once dispatched and nothing for
an unknown spell or a token that names no unit. Macros calling it tag
their primary spell like CastSpellByName macros.
2026-10-28 11:22:17 -05:00
Brues fcd7e52be0 docs: SetBagPortraitTexture, SetInventoryPortraitTexture 2026-10-28 11:22:16 -05:00
Brues 886a4cb140 feat(container): SetBagPortraitTexture, SetInventoryPortraitTexture
Register the client's misspelled SetBagPortaitTexture under its correct
name and as C_Container.SetBagPortraitTexture. Fix the engine's
SetInventoryPortaitTexture, which reads the unit token from argument 1
(the texture) and so only ever cleared the texture, by patching that
index to 2, and register it as SetInventoryPortraitTexture.
2026-10-28 10:25:29 -05:00
Brues e30da939ec fix(lossofcontrol): let immunity-granting spells through their CC
GetSpellLossOfControlCooldown treated Polymorph (and every stun, fear,
confuse, charm) as blocking every spell, so a druid form reported a
lockout while polymorphed. The forms grant Polymorph immunity, and the
client's caster validator lets such a spell through via its per-arm
crowd-control scans. Blocks() now asks that arm's engine scan instead of
restating the gate.

Fixes #114
2026-10-28 02:25:06 -05:00
Brues 6e4efb39f7 feat(lua): Blizzard_Menu
Menu, MenuUtil, MenuTemplates, the Compositor and the DropdownButton
intrinsic, ported from retail with SecureTypes, ProxyUtil and RegionUtil,
and the Vanilla/Classic variants. The engine pieces are the earlier
commits: motion focus and propagation, the click/motion split,
IsMouseMotionFocus, SetTextToFit, SetToDefaults and region scale.

Port changes:
- The Compositor calls 1.12's frame __index function instead of indexing a
  method table.
- 1.12's script SetFrameLevel leaves children behind, so the menu takes its
  level before Open() raises the elements above it, and scroll mode brings
  the ScrollBox and ScrollBar up through SetParent(nil) then SetParent(menu).
- 1.12's SetScript does not turn mouse input on, so the menu, its elements
  and the auto-hide button do it where they set the mouse scripts.
- One window: no GetWindow/SetWindow. No taint: no SetForbidden or secure
  delegate.
- A submenu opens past its parent's right inset, at the parent's edge, as
  1.12's dropdowns do; retail's atlases hide the overlap that 1.12's opaque
  borders show.

The Classic atlases are 1.15 art, so the styles are drawn with 1.12's:
dropdowns with UIDropDownMenuTemplate's label frame and scroll-down button,
the filter dropdown with the stretched panel button, MenuStyle1/2 with
DropDownList's dialog and tooltip backdrops (cleared when a menu frame is
released), the dropdown check, the radio button sheet and the help frame
divider's middle slice. Insets and 10 of row padding keep arrows, swatches
and text clear of the opaque borders. ColorSwatchTemplate's texture
sublevels become layers. WowStyle2 and the highlight radio are left out
until they have 1.12 art.

Retail's global nop, assertsafe and CallErrorHandler (EnvironmentUtil,
ErrorUtil) replace the local copies in FunctionUtil, Pools and LinkUtil.
Also GameTooltip_SetTitle, LIST_DELIMITER, IsScrollingMessageFrame and
UIResetButtonTemplate.
2026-10-28 02:13:05 -05:00
Brues 8c21ab4b04 fix(region): give a region its frame's scale when it gets the frame
Vanilla gives a texture or font string its frame's scale only when it links
the region into a draw layer, which happens once the frame is visible. Until
then the region keeps scale 1.0: a region made on, or moved to, a hidden
frame lays out in the wrong units and GetLeft/GetRect report its position
divided by the wrong scale. Probed under a 0.91-scale UIParent: on a hidden
row a texture anchored to the row's left edge reported 454,446 against the
row's 499,498, the same ratio after moving the row; on a shown row the two
matched. Retail regions carry their frame's scale shown or hidden, and
Blizzard_Menu measures its rows from their regions while they are hidden.

CScriptRegion::SetRegionParent's call to CSimpleFrame::AddRegion now also
makes the call AddRegionToLayer would make later: the region layout's own
scale setter with the frame's layout scale, unforced. The draw layer is
still joined at show.
2026-10-28 02:12:52 -05:00
Brues 7ec3829eb5 feat(region): SetToDefaults
Retail's contract, probed on 5.5.4: a frame, texture or font string goes
back to the state its class constructs it in, not the one it was created
in, so template values, a font string's creation font and a button's
construction-time mouse input are lost. The parent, keyboard and wheel
input, registered events, child regions and Lua fields stay. 1.12's
constructor values agree with the probed reset (MEDIUM, parent level + 1,
no font, white text, black shadow at 0,0, CENTER/MIDDLE, gradient -1).

Values reset through each object's own methods, looked up through its
vtable's method lookup, so neither table fields nor a replaced metatable
(Blizzard_Menu's Compositor replaces them) are consulted and every backport
keeps its state in step. Engine paths where no method expresses the
default: every script slot released and emptied as SetScript(nil) does;
Frame::Attributes::ClearAll, firing no OnAttributeChanged; the text of a
font string or button label through the font string's SetText member; the
font unlinked and cleared through the engine's own no-font store; the
follower mask and alpha gradient back to their constructor values.

Verified against the retail snapshot property by property on 1.12. Not
copied: retail leaves a hidden frame IsShown() but not IsVisible() after
the reset; this one shows it.
2026-10-28 01:15:46 -05:00
Brues 86bfd881a9 feat(fontstring): SetTextToFit
Retail's contract, probed on 5.5.4: set the text, then the explicit width
becomes the text's unbounded width, whether the old width was smaller or
larger and whatever the max lines; the height is not touched; no or empty
text leaves the width unset. The text goes through the engine's own Lua
SetText, so conversions, grammar escapes and the "Font not set" error are
SetText's; the width through CLayoutFrame::SetWidth with the same substring
measure GetUnboundedStringWidth uses.
2026-10-28 01:00:07 -05:00
Brues cd55ce9139 feat(frame): separate mouse clicks and mouse motion
frame:SetMouseClickEnabled / SetMouseMotionEnabled / IsMouseClickEnabled /
IsMouseMotionEnabled and SetPropagateMouseClicks / CanPropagateMouseClicks,
with enableMouseClicks, enableMouseMotion and propagateMouseInput="Clicks"
in XML. Retail (probed on 5.5.4) routes the two separately: motion goes to
the top motion-enabled frame, a press to the top click-enabled frame, so a
click on a motion-only frame reaches the frame under it and a click-only
frame takes clicks without ever taking the motion. A frame that propagates
clicks passes the press on; each gets it and then the release, top-first.

The engine's one mouse bit stays "click or motion"; Frame::MouseInput keeps
the frames with one half off. The focus pass's probe call skips frames with
motion off. The press handler's focus read becomes the click walk, which is
that same read while no frame uses the split or click propagation; the press
and release dispatches run each extra target's button slot after the
engine's.

EnableMouse sets both halves. IsMouseEnabled is both halves and still
answers 1 or nil as the engine does: pfUI compares it with SpellIsTargeting.
2026-10-28 00:50:56 -05:00
Brues 1f8042088c feat(frame): mouse motion propagation and the motion focus list
Retail's motion focus is a list (probed on 5.5.4): the walk takes the top
mouse-enabled frame under the cursor and keeps going while the last frame
taken propagates motion; the next frame is the next one under the cursor,
not the parent. Every listed frame is focus, a frame that stays listed
across a change gets neither leave nor enter, and the ones that drop out
leave before the ones that join enter, each top-first.

The three blocks that write the UI context's focus (+0x7C) - the focus
pass's commit, the mouselook focus setter and the input removal - now call
Frame::MotionFocus, which keeps the list and keeps +0x7C as its first
frame. A propagating pick resumes the engine's walk through the same
per-frame probe. A removed frame leaves wherever it is in the list and asks
for a re-pick; hiding a frame removes it before its children, which gives
retail's outer-first leave order.

- frame:SetPropagateMouseMotion / CanPropagateMouseMotion /
  IsMouseMotionFocus, and propagateMouseInput="Motion"|"Both" in XML.
- GetMouseFoci returns the list (one frame unless frames propagate), as
  retail does, instead of every frame under the cursor.
- EnterLeaveMotion offers motion to the frame MotionFocus is notifying.
- Common::ReplaceWithCall takes up to 64 bytes; it refused anything over
  16 without a word.
2026-10-28 00:37:13 -05:00
Brues a86d5ec48f feat(lua): BackdropTemplate
Port retail's Blizzard_SharedXML/Backdrop: the BackdropTemplate template,
BackdropTemplateMixin, and the BACKDROP_* tables (verbatim). Retail draws
the backdrop with nine textures of its own because its frames lost the C
backdrop; 1.12's frames still have it and it lays out the same nine slices
(benilla-re ui/backdrop), so the mixin keeps retail's state and methods
(backdropInfo, HasBackdropInfo, ApplyBackdrop, ClearBackdrop, GetEdgeSize,
the OnLoad key values) and draws through the engine's SetBackdrop.
SetBackdrop with the table already applied keeps the colours, as retail's
does. GetBackdrop and the colour methods stay the engine's own.

SetBorderBlendMode and backdropBorderBlendMode are not ported: Lua cannot
reach the engine's edge textures.
2026-10-27 23:56:10 -05:00
Brues 70dadf0449 fix(aura): player aura duration after login
SMSG_UPDATE_AURA_DURATION carries the holder's remaining time. On login
the server sends it for auras the character already held, so their
duration read as the time left instead of the total. Flag those slots
(no player object yet on a fresh login; occupied slot with no earlier
duration on a reconnect) and report the spell's base duration instead,
or the remaining time where a caster modifier stretched it past the base.
2026-10-27 23:40:30 -05:00
Brues 5363434439 build: standard section layout, no writable code
/SECTION:.text,EWR made the code section writable and the .rdata merge
put constants in it. Nothing needs either: engine patches make their
target writable through Common::PatchBytes, and a scan of every
function in the DLL finds no write into .text or .rdata. A writable,
executable section is a common antivirus heuristic. Same file size.
2026-10-27 23:24:17 -05:00
Brues 9bac760195 build: gzip the embedded addons and compile for size
The embedded addon Lua/XML and Bindings.xml were stored as raw RCDATA,
924 KB of the 2.38 MB DLL. Each resource is now gzipped at build time
(cmake/gzip_file.cmake, CMake's own ARCHIVE_CREATE) and inflated with
the engine's zlib on first use, so Embed::Resource keeps its contract:
the same bytes, valid for the life of the DLL. Encoding::Compress now
exports the Inflate it already had for C_EncodingUtil.

/O1 replaces /O2 /Ob2: 280 KB less code. /GL + /LTCG made it larger.

2,380,288 -> 1,430,016 bytes.
2026-10-27 23:24:17 -05:00
Brues bd02983a08 feat(lua): WowTrimHorizontalScrollBar
Retail's horizontal bar is its vertical one with x and y swapped. Classic
Era has no horizontal bar and this client no horizontal scroll bar art, so
the template is WowClassicScrollBar with every anchor, size and inset
swapped, and retail's horizontal atlas names (UI-ScrollBar-EndCap-Left/
Right, _UI-ScrollBar-Center, UI-ScrollBar-ScrollLeft/RightButton-*, ids
from the 1.15 member table) bind to the Classic art transposed: the top cap
is the left cap, the up arrow the left arrow. Transposing keeps the art's
top-left lighting, which a quarter turn would move. The square knob takes
the swapped crop of the same file.

Atlas entries gain a transposed flag: SetTexCoord composition maps sprite x
down the file rect and y across it. An inline |A icon is one axis-aligned
rect, so a transposed atlas is not drawn there.
2026-10-27 23:01:36 -05:00
Brues 852cf44b7d feat: frame:SetAlphaGradient and frame:DesaturateHierarchy
SetAlphaGradient(index, gradient), ClearAlphaGradient and HasAlphaGradient,
what ScrollBox's SetEdgeFadeLength calls. As probed on retail: index 0 fades
in from the left and top edges, index 1 from the right and bottom, and the
fade shows only while the frame clips its children, its own regions
included.

Each index is one texture stage over the draw: a 2x2 ramp texture, filtered
and clamped, whose alpha multiplies in (preset 1). Coordinates come from the
camera-space position through a projected texture matrix that maps clip
space to the screen and then to the ramps, reloaded on every projection and
viewport set while a gradient draws. Textures, text (drawn in pixels under
its own projection) and clipped draws fade on the same screen spot.
ClipChildren now batches regions by nearest clipping frame and nearest
frame with a gradient; Texture::Mask takes its stages above the gradient's.
D3D only; a desaturated or masked texture does not fade.

DesaturateHierarchy(desaturation [, excludeRoot]) sets the desaturation of
every texture of the frame and of the frames under it, through
Texture::Desaturation.
2026-10-27 22:53:15 -05:00
Brues e59f9de5a8 feat(lua): SOUNDKIT constants
Verbatim port of Classic Era's Blizzard_SharedXML/Vanilla/SoundKitConstants.lua.
PlaySound already plays by SoundKitID, so the scroll bar arrows'
SOUNDKIT.U_CHAT_SCROLL_BUTTON now resolves and clicks.

109 of the 383 ids exist in 1.12's SoundEntries.dbc, with matching names
(1115 = UChatScrollButton, 850 = igMainMenuOpen); the rest are later sounds
and play nothing.
2026-10-27 21:37:51 -05:00
Brues 59164e3575 fix(lua): scroll bar knob drag and bottom end cap
ScrollBar's thumb drag and track paging read the cursor through
InputUtil.GetCursorPosition, which was never ported, so both handlers
errored on their first line. Port retail's InputUtil.lua and the
GetAppropriateTopLevelParent family it uses into FrameUtil.lua.

UI-ClassicScrollBar-EndCap-Bottom used UI-Character-ScrollBar's upper
bottom cap, whose 3-row border put the Forward stepper over the cap's
separator. Bind it to the lower cap (rows 227..255), whose 4-row border
and 17-row well fit the template's stepper exactly.
2026-10-27 21:31:47 -05:00
Brues 711c810b72 feat(lua): retail's ScrollBox, with Classic Era's scroll bar
Port Blizzard_SharedXML/Shared/Scroll (ScrollBox, its views, ScrollBar,
ScrollUtil, ScrollController, ScrollTemplates, TrimScrollBar.lua) and the
helpers it needs (Flags, FrameFactory, TemplateInfoCache,
TreeListDataProvider, ButtonStateBehavior). Verbatim except:
- TemplateInfoCache asks C_XMLUtil directly; retail's forbidden delegate
  is only taint isolation.
- Textures without <Anchors> get setAllPoints="true". Retail's loader
  fills such a texture to its parent (probed); 1.12's does not, and
  cannot be made to without breaking its own UI, whose TalentFrame
  anchors unanchored branch textures with one SetPoint.

The scroll bar is Classic Era's WowClassicScrollBar. Its atlases are
Blizzard's repack of vanilla's scroll bar art, bound in AtlasData.h to
the 1.12 files that carry it (UI-Character-ScrollBar for the track, the
UI-ScrollBar-Scroll*Button files for the steppers, each arrow measured
at the member table's 19x17). Its highlights draw with ADD, as the
client's own scroll buttons draw that art. WowTrimScrollBar and
MinimalScrollBar, whose art the client does not have, are
WowClassicScrollBar.

Also TextureKitConstants, and Pools.lua's AddSourceLocationExclude.
2026-10-27 21:22:22 -05:00
Brues dceec8b74c feat: engine pieces for retail's ScrollBox
What the ScrollBox port needs from the DLL, and three engine bugs it hit.

Fixes:
- GetWidth / GetHeight answer a rect-derived size over the layout scale,
  as 4.3.4 does. 1.12's getters leave the division out, so an anchored
  region under a UI scale read scaled (a 160-high track at 0.91 answered
  145.6, and the scroll bar thumb stopped short). GetRect and
  GetScaledRect use them.
- A <Size>, <Anchor> or <Offset> with only `x` applies. The engine's
  dimension reader decides "no attributes" from `y` alone; a co-hook
  answers as 4.3.4 does. Xml::Anchors' "0" attribute workaround goes.
- KeyValue type="global" walks a dotted path, as retail's own templates
  need (ScrollControllerMixin.Directions.Decrease).

New:
- <Texture atlas="..." useAtlasSize="...">, applied as SetAtlas.
- securecall / securecallfunction / secureexecuterange, as probed on
  retail: results returned, errors to the error handler, the range walk
  goes on after one.
- AddSourceLocationExclude(fileName): GetSourceLocation steps over that
  file's levels.
- region:AdjustPointsOffset(x, y) / ClearPointsOffset(), re-setting each
  anchor through the engine's SetPoint. Retail, probed: Clear zeroes the
  offsets rather than undoing adjustments.
- Enum.InputContext.
2026-10-27 21:22:22 -05:00
Brues 9edce4a9f9 feat(xml): intrinsic frame types, script binding types, EventFrame
A top-level frame element with intrinsic="true" defines a frame type:
<EventFrame> and CreateFrame("EventFrame") build a frame of the element's
own type with the definition applied first (its mixins, content and child
frames, then the templates and the element's own). Retail's EventFrame,
EventButton, EventEditBox and EventScrollFrame are ported verbatim;
ScrollBox and its scroll bars are EventFrames.

The builder keeps a definition instead of building it, detached from its
document the way the template registrar keeps a virtual node, and
registers its name with FrameXML_RegisterFactory. The one factory for
every intrinsic name builds the base type, for the node the builder hook
is building.

An intrinsic's scripts are its intrinsic bindings. Each script now has
retail's three (LE_SCRIPT_BINDING_TYPE_INTRINSIC_PRECALL, _EXTRINSIC,
_INTRINSIC_POSTCALL), as probed on retail:

- SetScript and GetScript read and write the extrinsic binding, so
  SetScript(script, nil) keeps the intrinsics.
- GetScript(script, binding) reads one; HookScript(script, hook) hooks
  the extrinsic, so the hook runs before the postcall, and
  HookScript(script, hook, binding) hooks an intrinsic.
- An intrinsic element without intrinsicOrder binds the precall.

A slot with intrinsic bindings holds a dispatcher closure over the three,
which runs each in its own context under the script error handler. Every
writer of a slot releases its old handler first, so after SetScript and
after each XML node load a new handler moves into the extrinsic binding.
The check compares the closure, not the ref number: luaL_ref hands the
released number straight back. Frame::ScriptArgs looks inside the
dispatcher as it does the HookScript wrapper.
2026-10-27 20:18:13 -05:00
Brues 4e3a18cdd1 feat(frame): clipChildren and SetClipsChildren/DoesClipChildren
A frame that clips its children draws every frame under it only inside
its own rect, and the mouse reaches them only there. Retail's ScrollBox,
talent tree and settings list rely on it.

Retail keeps clipped frames in their own strata and levels (the talent
tree puts a button's glow at the button's level - 1 inside a clipping
frame), so the clip applies per level instead of drawing the subtree in
one piece as 1.12's ScrollFrame does:

- The level rebuild's per-frame layer call (0x007659E7) is redirected.
  A frame under a clipping frame fills an engine batch of its own, one
  per level batch and nearest clipping frame, and the level batch gets
  a render callback that draws it right after the level's own regions.
- That callback is the ScrollFrame's draw (0x00787220) with the clip
  rect: save the transforms, intersect with the current viewport, set
  the ortho transforms, narrow the viewport, draw, restore.
- A ScrollFrame's own subtree draw calls each child at 0x0076B511; that
  call is redirected too, so a clipping frame inside a scroll child
  clips. Nested clips hold because each draw intersects with the
  viewport already in place.
- The hit test also misses outside every clipping ancestor's hit rect.
- Turning the clip on or off and SetParent mark the moved frames'
  layers changed, since SetParent does not relink a frame whose level
  and strata stay the same.

Offsets.h gains the batch, device-transform and call-site entries, and
the level-rebuild comment now says what the function actually does.
2026-10-27 19:44:43 -05:00
Brues fc8fba65cc refactor(frame): share the SetParent hook through Frame::ParentChange
MinHook allows one hook per address, and clipping needs to follow a
frame's parent as Frame::Alpha does. Frame::ParentChange now owns the
SetParent hook and runs its listeners after the engine moves the frame,
the way Frame::Destruction owns the destructor. Frame::Alpha is its first
listener, with the same behavior.
2026-10-27 19:44:42 -05:00
Brues f01a059cc0 feat(frame): useParentLevel and SetUsingParentLevel/IsUsingParentLevel
A frame that uses its parent's level draws at the parent's level instead
of one above it. Retail's ScrollBox and TrimScrollBar templates rely on it
for their scroll target and backgrounds.

4.3.4 keeps this as frame flag 0x100000, and its level setter replaces any
level it is given with the parent's while the flag is on. 1.12's setter
(0x0076A4F0) is the same function without that test; a hook adds it, so
SetParent, a parent moving its children, and SetFrameLevel all land on the
parent's level. Setting the flag moves the frame and its children to the
parent's level, as 4.3.4's SetUseParentLevel does.

The XML attribute is read after the node loads, when the frame has its
parent, and only when present, so a node's value overrides its template's.
2026-10-27 19:17:03 -05:00
Brues 8d22876c01 fix(debugtools): seat the table inspector's close button in its title bar
The stock 32x32 UIPanelCloseButton overhung the 19px title bar and sat on
the border. Size it 24x24 at the same inset as OpenParentButton so both
title-bar buttons share one centre line.
2026-10-27 18:56:18 -05:00
Brues 39a14054fe feat(frame): AnchorUtil and LayoutFrame, with their region methods
Port retail's AnchorUtil, GridLayoutUtil and LayoutFrame (Lua and XML
templates) to !!!ClassicAPI. They are verbatim except PrintAnchorGraph,
which prints to chat since 1.12 has no C_Log. GetUnscaledFrameRect and a
GMError that does nothing (a player client is never a GM client) come
along.

The region methods they call:
- GetPointByName(point) through GetPoint's own pusher.
- ClearPoint(point): ClearAllPoints (0x767620) for one slot, dropping
  the dependent bit through 0x767FA0 and deleting the node.
- IsRectValid(): the stock edge getters' steps, so it is true exactly
  when GetLeft answers a number.
- GetScaledRect() on every region.
- GetScale/GetEffectiveScale on textures and font strings: the layout
  scale the frame hands each region, and 1.
- ignoreRect on GetWidth, GetHeight and GetSize: a 0 explicit size stays
  0 instead of falling back to the rect.
2026-10-27 18:49:24 -05:00
Brues 7e1edce462 feat(xml): inherit="prepend"/"append" on <Scripts> handlers
A handler element with inherit="prepend" now runs the inherited
template's handler first and then its own; "append" runs its own
first. Vanilla's loader let the node's handler replace the template's.

Template nodes load onto the same frame before the node's own content,
so the handler a template set is captured when that template's load
returns, then joined with the node's handler (body, function= or
method=) in a C closure that calls both with the same arguments.
Elements apply in document order. The function= binding skips scripts
that ScriptInherit sets.

Also binds luaL_unref as Game::Lua::Unref and drops an unused local
typedef for it.
2026-10-27 18:23:40 -05:00
Brues e7b6c39ace feat(debugtools): port retail's table inspector
Replace the minimal /tinspect window with retail's Blizzard_TableInspector
and its anchor and attribute providers. The window lists a table's
anchors and its fields grouped by type, with child frames and regions,
editable numbers, strings and booleans, a filter, back and forward
history, open-parent, duplicate, and Show / Highlight / Dynamic Updates
toggles. The provider XML is retail's, unchanged.

1.12 changes to the main window: a backdrop and plain title bar replace
the gear-manager art, the scroll-up and plus buttons replace two retail
textures, and the scroll frame and filter box are small unnamed templates.
Windows come from a frame pool and have no names, so the stock
InputBoxTemplate's $parent-named border and UIPanelScrollFrameTemplate's
named scroll bar would be shared by every window.

/tinspect works as on retail: a table result opens on it; otherwise it
opens on the frame under the cursor, or UIParent. Ctrl in /fstack opens
TableAttributeDisplay. The frame highlight (AnchorHighlightMixin,
FrameHighlightTemplate) comes from retail's Blizzard_DebugTools.

!!!ClassicAPI gains retail's TruncatedTooltipScriptTemplate and
PanelDragBarTemplate, GetAppropriateTooltip, GameTooltip_AddNormalLine
and GameTooltip_AddColoredLine.
2026-10-27 18:06:55 -05:00
Brues 9027e22e4f feat(xml): <Anchor> x/y and relativeKey, parentArray
Retail templates write anchor offsets as attributes and name anchor
targets by key path. 1.12's anchor loader reads point, relativePoint,
relativeTo and an <Offset> child only, so those anchors landed on the
parent with no offset.

After each frame, texture and font string loads, Xml::Anchors re-sets
every <Anchor> that has relativeKey or x/y. The target is the key path,
or the one the engine's own anchor node resolved. The offsets go through
the engine's XmlReadDimension (0x006F1EB0), the reader the loader uses
for <Offset>. The point is set with the engine's setter.

A frame loads its layers before its child <Frames>, so a layer anchored
to a child frame cannot resolve at load. Such anchors are queued and
retried once a frame's children are built, before its OnLoad, and again
when the outermost build returns, which reports what is still missing.

parentArray="Name" appends the region to parent.Name in load order.
2026-10-27 18:06:45 -05:00
Brues 7add0fc88f feat(frame): region:GetDebugName([preferParentKey])
Retail's debug name for a region: its global name; else its parent's
debug name, a dot, and the key that holds it in the parent's Lua table,
or its address in hex when no key holds it; "" for an unnamed region
with no parent. preferParentKey puts the key path ahead of a global
name. The key comes from the parent's table, so a plain Lua assignment
resolves once the region is parented, as on retail.

The frame stack tooltip prints names through it, which removes the
debug tools' local _DebugName. Game::Lua::ObjectName exposes the
engine's GetName virtual for unnamed-aware callers.
2026-10-27 18:06:38 -05:00
Brues 04ce033907 feat(lua): retail SharedXML utilities and GetTickTime
Port six retail SharedXML files to !!!ClassicAPI: DataProvider,
CircularBuffer, EasingUtil, Interpolator, EnumUtil and StringUtil. Five
are verbatim. CircularBuffer drops retail's securecall and SecureTypes
wrappers, since 1.12 has no taint system. CallbackRegistry and
IconDataProvider now use EnumUtil.MakeEnum in place of inlined tables.

GetTickTime() returns the frame delta that OnUpdate receives, which
Interpolator needs. The engine keeps no copy of it after the frame
pass, so the UI render hook latches the value before any OnUpdate runs.
Events and C_Timer callbacks run earlier in the tick and see the
previous frame's value.
2026-10-27 18:06:17 -05:00
Brues 1f3ce9229d refactor: move the embedded addons into src/embed/AddOns
Users saw AddOns/!!!ClassicAPI at the repository root and downloaded it,
thinking the mod is only an addon. The addons are source code for the
DLL, so they now live next to the resource loader in src/embed/. The
README says that the DLL contains them and that the folder is not for
copying into the game.

Remove the AoELootTest dev addon.
2026-10-27 16:44:30 -05:00
Brues 091d2204a6 feat(debugtools): embed the debug tools in the DLL, load on demand
The DLL now embeds a list of addons instead of only !!!ClassicAPI. The
debug tools ship as ClassicAPI_DebugTools, a hidden, always-enabled
LoadOnDemand addon. /dump, /fstack, /etrace, /tinspect and /luaerrors
are stubs in !!!ClassicAPI that load it on first use, the way retail's
chat frame loads Blizzard_DebugTools.

The Blizzard_ name is not usable: the engine moves loose
Interface\AddOns\Blizzard* folders aside at startup and refuses a
Blizzard_* TOC without a signature.
2026-10-27 16:13:09 -05:00
Brues d93861d746 feat(tooltip): frame stack highlight, anchors, and GetSourceLocation
Retail's frame stack tooltip marks one entry and, after the list, prints
its anchors and the script that created it. Port both halves.

SetFrameStack now takes (showHidden, showRegions, highlightIndexChanged)
and returns the highlighted entry, as the current client does. The
marked line opens with "-->", regions list after their frame in
FRAMESTACK_REGION_COLOR, and ANCHORS / SOURCE sections follow the list.
The formats are the client's own, read from its string table; the
non-highlight indent and the ANCHORS header are inferred from it. Stratum
0 prints as WORLD, which 4.3.4's FrameStrataToString names and 1.12's
table does not. OnTooltipSetFrameStack now carries the highlighted entry
as its argument.

region:GetSourceLocation() (9.2.5) records where each frame was built:
a Lua CreateFrame takes the caller's "file:line" from lua_getstack /
lua_getinfo "Sl" inside the call, stepping over C levels, with the
chunk name as luaO_chunkid writes it; an XML frame takes the file
FUN_XML_LOAD_FILE is processing. Regions answer for their parent frame.
The XML file tracker moves out of Xml::ScriptPath into Xml::FileStack so
both consumers share it.

GetPoint on a screen anchor: CSimpleTop is a bare CLayoutFrame, and
Script SetPoint stores it as the relative layout for a nil relativeTo.
The stock reader forms `layout - 0x24` for it and reads the 36 bytes
before the top object as a region header. Frame::GetPointGuard now
implements GetPoint itself and pushes nil for the top, as 3.3.5 and
4.3.4 do; the frame stack's anchor lines test for it the same way. The
guard's header drops the claim that the engine never clears dependents'
anchors: CLayoutFrame::DestroyLayout does.

DebugTools: the frame stack runs the modern FrameStackTooltip on the new
method. Left/Right Alt move the mark, Ctrl (released) opens the marked
entry in the TableInspector, Shift adds texture information, fsobj is
set after an Alt press, and /fstack reads [showHidden [showRegions]]
through StringToBoolean. The modifier keys come from
MODIFIER_STATE_CHANGED instead of retail's polled KeyCommand table. The
green FrameStackHighlight cover and showAnchors are not ported: the
cover frames sit at TOOLTIP strata under the cursor and become the top
entry themselves.
2026-10-27 15:11:09 -05:00
Brues d9827852c7 docs(readme): fold Lua 5.1 into one Highlights table
Condense the six-row Lua 5.1 section into two rows of a single
Highlights table, and trim the soft targeting, combat log, and
#showtooltip rows. Full detail stays in docs/API.md.
2026-10-27 12:32:37 -05:00
Brues 2e75812339 fix(loot): stop unanswered loot requests from blocking all looting
The engine holds the player's loot target as it sends CMSG_LOOT, and
CanLootNow refuses every later loot while it is held. With no window
open only a server reply clears it, so a request the server never
answered locked looting until relog. LootAllCorpses while mounted hit
this reliably: the engine's right-click skips looting while mounted,
but FUN_CMSG_LOOT_UNIT has no such gate.

All loot senders now go through Loot::Request, which refuses while
mounted, reports whether the engine actually sent, and on timeout
releases a still-held target the way the release response does.

Fixes #113
2026-10-27 12:21:40 -05:00
Brues 1454dbe00c feat(spell): report isOnGCD from C_Spell.GetSpellCooldown
The engine's cooldown reader keeps the latest-ending of a history
record's spell, category and global legs. isOnGCD is true when a live
global leg carries exactly the start and duration it reported.
2026-10-27 12:08:47 -05:00
Brues 259071b647 fix(spell): report GetSpellInfo ranges through the engine resolver
GetSpellInfo and C_Spell.GetSpellInfo read the raw SpellRange.dbc row, so
range talents, spell mods, the ranged-item scale and the 0x404 flat range
were missing. Read min/max through FUN_GET_MIN_MAX_RANGE for the caster
(player, or pet for a "pet" book slot), and report 0/0 for a melee row,
as 4.3.4's Script_GetSpellInfo does.
2026-10-27 12:03:05 -05:00
Brues 2d887df71c fix(compat): stop Outfitter's minimap button acting as if dragged
Outfitter never initializes OutfitterMinimapButton.IsDragging, so a read
before the first drag falls through to the region:IsDragging() method
and is truthy. Its update frame then calls UpdateDragPosition every
frame with an unset CursorStartX. Set the field to false on load.
2026-10-27 10:19:15 -05:00
Brues 317539dbd8 fix(cooldown): keep a reverse frame's swipe full while its flash plays
A reverse sweep covers the icon as time passes, so at the end the icon is
fully dark. The sweep's end switched the frame to its flash state, where
UpdateColors cleared the swipe for both directions, and the flash's first
key has alpha 0. For the frames between the client-side end and the
aura's removal from the server (pfUI buffs with reverse timers), the bare
icon showed bright before the button hid.

3.3.5 and 4.3.4 never show this: they pin a reverse cooldown at
duration - 1 and never flash or hide it, so the icon stays covered until
the addon hides the button. The port took retail's timer semantics for
reverse (OnCooldownDone, flash times, hide after the flash) and dropped
the swipe with the hold.

Keep the retail state machine and 4.3.4's look: in the flash state a
reverse frame draws the full fan (what 4.3.4's flash branch draws for
reverse), set every tick so a timer already over at its first update does
not draw ResetVerts' zeroed fan, and UpdateColors keeps the swipe colour
for a reverse frame. The edge still goes with the flash, as in 4.3.4.

The docs/API.md line for SetReverse (direction corrected: the sweep starts
empty and becomes full) went in with 85b1365.
2026-10-27 01:16:47 -05:00
Brues 10d71371dd fix(equipmentset): pack main-bank locations as inventory slot ids
C_EquipmentSet.GetItemLocations encoded an item in the main bank as
BANK | bankSlot (1..24). The engine form, in both 4.3.4 and 3.3.5
Script_GetEquipmentSetLocations, is BANK | inventorySlotId: the value
BankButtonIDToInvSlotID returns (id + 39 in 1.12, verified), so bank
slot 3 is 0x40002A, not 0x400003. FrameXML relies on that form -- a
location without the BAGS bit goes straight to
GetInventoryItemID("player", slot) in
EquipmentManager_GetItemInfoByLocation, so the old code made the
shared helpers (and our EquipmentManager_GetLocationData) read the
paperdoll instead of the bank.

Add INVSLOT_BANK1 = 40 to Offsets.h, pack through it in PackMainBank
and invert it in UnpackContainer so UseEquipmentSet keeps swapping
from bag -1 with the 1-based slot Item::Swap::FromBag takes. Docs now
show the inventory-slot form and the 24-slot main bank.
2026-10-27 01:16:10 -05:00
Brues 4959c2c38c docs(sound): stop naming a file count for PlaySoundFile
API.md said only two files from PlaySoundFile can play at once. That is
the engine's on-disk cap for its bus, but it is not what the client
enforces here: SuperWoW's UncapSounds cvar lifts it, and a probe on this
client played 16 files at once. Describe the limit without a number and
keep the part that is true everywhere: a file holds its place until it
ends, a fading file still counts, and the call returns false at the limit.
2026-10-27 00:23:33 -05:00
Brues cece5a4214 feat(string): add strlenutf8, strcmputf8i, strsplittable and strconcat
The four Blizzard string globals still missing from the base library.
strlenutf8 and strconcat are 4.3.4's (a count of non-continuation bytes,
and lua_concat over the whole stack); strcmputf8i and strsplittable are
the newer clients'. Text::Utf8 mirrors 4.3.4 Storm's SStrLenUTF8 and
SStrCmpUTF8I, decoder and case fold included, since 1.12's Storm has
neither. strsplit and strsplittable share one scan; the table form never
grows the Lua stack. string.concat is the by-value twin of strconcat, as
string.split is of strsplit. Adds the lua_concat binding (0x006F44E0).
2026-10-26 23:49:10 -05:00
Brues 9daf3960c4 feat(combatlog): add SPELL_AURA_REFRESH, overkill and overhealing
SPELL_AURA_REFRESH is 4.3.4's "same spell in the slot, duration updated".
Aura::Source gains a Refreshed change: the player's SMSG_UPDATE_AURA_DURATION
for an occupied slot whose duration was already known (an application's
packet comes before the descriptor write, so its slot reads empty; the login
burst — Map::ExistingPlayerLogin, the one path that re-sends every duration
— finds no earlier duration because the table is cleared with the glue
screen), and for other units the recast the cache refreshed or a server
refresh mechanic it mirrors. Pending refreshes resolve at the world tick
against the descriptor, so a slot that changed hands in the same tick is an
application, not a refresh. The tick-speed compression nudge and duration
reductions do not count.

Overkill and overhealing follow 4.3.4's predicted health. The server sends
each damage and heal message before the tick's health update, so the target
still shows the health before it (verified live: 148, 113, 91, 63, 37, 10
under Fireballs of 34, 22, 27, 26, 27, then the kill); the messages since the
field was last read are kept as a delta so a tick's second hit counts its
first. A unit whose health the server hides (maximum 100, the stock
ShowHealthValues setting) gets 0.

Verified live: Frost Armor recast -> SPELL_AURA_REFRESH, nothing at login or
on a zone change; a 772 Fireball on a 194-health boar -> overkill 578.
2026-10-26 21:29:50 -05:00
Brues 39ab9eb1ee feat(combatlog): name the damage shield, the dispelling spell and the instakill caster
Three values 1.12's messages leave out, read from what the client already
holds at the moment each message arrives.

DAMAGE_SHIELD: the owner's one damage-shield aura (EffectApplyAuraName 15)
of the damage's school names the shield — Thorns, Fire Shield, Retribution
Aura. Two of one school on one owner, or an owner out of view, keep the
unnamed form.

SPELL_DISPEL, SPELL_DISPEL_FAILED and SPELL_INSTAKILL: the server sends each
from the spell's effect handler, in the same tick as and after that cast's
SMSG_SPELL_GO. CombatLog::RecentCasts keeps the last second of hit lists,
fed from Casts.cpp's existing SPELL_GO parse, and names the caster's newest
cast at the target with a dispel effect (38, 108), or the caster of the
newest cast of the instakill spell at the victim. A dispel from an aura
(Abolish Poison) has no cast and stays unnamed.

Verified live: Remove Lesser Curse -> SPELL_DISPEL 475 "Remove Lesser Curse"
"Curse of Tongues"; a druid's Thorns -> DAMAGE_SHIELD 9910 "Thorns".
2026-10-26 20:55:52 -05:00
Brues e8bd35d8e4 feat(combatlog): log immune damage and summons from packets 1.12 already sends
SMSG_SPELLORDAMAGE_IMMUNE (0x263) becomes DAMAGE_SHIELD_MISSED / IMMUNE
(SPELL_PERIODIC_MISSED when the debug byte is set), the mapping 4.3.4 and
3.3.5 use. 1.12 writes its IMMUNESPELL line from the handler's own
formatter, not the miss writer Miss.cpp hooks, so the event never fired.

SMSG_SPELLLOGEXECUTE: read the summoned GUID the server writes for the
summon-family effects (tortoise Spell::SendLogExecute), which 1.12's reader
leaves unread in the buffer. A unit is SPELL_SUMMON, a game object or
dynamic object SPELL_CREATE. Portals stay silent: every portal spell has
Attributes 0x100, the hide-from-log bit both the engine's lines and 4.3.4
honor.

Verified live: Healing Stream Totem -> SPELL_SUMMON, Fishing -> SPELL_CREATE
"Fishing Bobber", Fireball on an immune Fireguard Destroyer ->
DAMAGE_SHIELD_MISSED IMMUNE.
2026-10-26 20:46:09 -05:00
Brues f4d8f56c59 feat(editbox): add history lines at the end of the ring, as retail does
The engine's history ring uses one position for writing and for Up/Down
browsing, so a line sent after browsing overwrote the line the browsing
reached, and later sends overwrote the newer lines after it. Keep each
box's write position beside the engine's field and put it back before
every add, so a new line always lands at the end and the next Up starts
from it. Like retail, a line equal to the newest one (case-sensitive) is
not added again.
2026-10-26 19:29:12 -05:00
Brues 3f33320724 feat(item): add IsDualWielding and drop the OffhandHasWeapon override
IsDualWielding() ports 4.3.4's Script_IsDualWielding (0x00452A50): true
when the off-hand item is weapon class and the player can dual wield.

OffhandHasWeapon is a native 1.12 global (0x004C9570, same body as
3.3.5/4.3.4: off-hand item class 2 -> 1, else nil). Our override
returned booleans and tested inventory type instead, so remove it and
let the engine's own function stand.

Item class IDs 2 (Weapon) and 12 (Quest) are now used in more than one
file, so they move to Offsets.h as ITEM_CLASS_WEAPON / ITEM_CLASS_QUEST
(verified against ItemClass.dbc).
2026-10-26 18:55:56 -05:00
Brues 492085ac0c feat(aura): add aura instance IDs and the UNIT_AURA_DELTA event
1.12 has no aura identity: a unit's descriptor holds 48 spell IDs and
nothing that tells one application from the next. Aura::Instance gives
each visible aura an ID when it lands in a slot and keeps it until the
aura leaves; IDs are never reused. One sync step (recorded slot vs live
descriptor) drives everything: the existing Aura::Source add / remove /
stacks observers sync the slot they name, the getters sync before
answering, and a per-frame reconcile catches bulk clears and rebuilt
objects. No new engine hooks.

UNIT_AURA_DELTA(unitTarget, updateInfo) carries retail's UNIT_AURA
payload under its own name, so the engine's UNIT_AURA is untouched. It
fires once per unit per frame, per token, with instance-ID lists only,
in four reused tables, so a fire allocates nothing. With no listener,
no change sets are kept. Timing-only refreshes (cache rewrites, the
player's duration packet) are reported as updated.

Also:
- auraInstanceID on every AuraData table and on C_LossOfControl CC
  entries
- C_UnitAuras.GetAuraDataByAuraInstanceID(unit, id [, buf]); the buf
  form fills the caller's table
- Game::Lua::Ref, the shared luaL_ref binding, replacing two local
  typedefs in the animation code
2026-10-26 18:39:03 -05:00
Brues 7b3b61de15 fix(lossofcontrol): take CC timing from the server's player aura timer
The CC scan read its timing from the cast-observation cache, which holds
a client-side estimate: base duration for another caster's spell, and no
diminishing returns at all. For the local player the server sends the
applied duration in SMSG_UPDATE_AURA_DURATION, and the engine keeps its
expiry in the player buff expiration table.

Record that packet's duration per slot and expose both through
Aura::Data::PlayerAuraTiming. C_LossOfControl uses it for every CC
debuff, and C_UnitAuras now reports the applied duration for the
player instead of the base or cached estimate.
2026-10-26 18:03:12 -05:00
Brues 77efe8d796 fix(unit): read feign death from the dynamic flags, as 3.3.5 does
UnitIsFeignDeath tested UNIT_FIELD_FLAGS bit 29. tortoise-wow sets that
bit next to UNIT_DYNFLAG_DEAD, but vmangos sets only the dynamic flag,
so the getter always returned false there. 3.3.5's
Script_UnitIsFeignDeath (0x0060F3D0) tests UNIT_DYNFLAG_DEAD.
2026-10-26 17:56:10 -05:00
Brues 8378f3c350 chore(doc): drop the per-pass registration count log
Every install wrote "[doc] <env> pass: N registrations recorded, M
undocumented" to classicapi_debug.log on each glue and game pass. It
helped no one, and _classicapi_UndocumentedAPI() already answers the
question on demand. Removes the line and the counter that only fed it.
2026-10-26 17:35:54 -05:00
Brues 1a2f806f16 fix(event): skip the slots the engine fires by number
A NULL name in the event table did not mean the slot was free. The
engine fires events by id, and two NULL-named in-game ids still fire:
the glue band (ids 0..25, including ADDON_LIST_UPDATE at 6, which the
SMSG_ADDON_INFO listener fires whichever table is installed) and chat
type 89's id 540. Our low walk put the 5th custom event on slot 6.

Claimable() now requires an id past GLUE_EVENT_COUNT that is not in the
engine's chat-type id table, read live. Both the claim walk and Grow's
headroom count use it. Verified against benilla-re's fire-site census
and this exe's own name initializer; a live dump puts our 77 events at
31..110 with nothing below 26.
2026-10-26 17:33:12 -05:00
Brues 90be617700 fix(equipmentset): play the drop sound when a held set is cleared
1.12's cursor clear plays INTERFACESOUND_CURSORDROPOBJECT from a kind
table that stops at 10, so the set (13) dropped silently. Co-hook it
with 4.3.4's set arm: reset the ID and play the sound, then run the
engine's shared tail.
2026-10-26 17:18:45 -05:00
Brues 45b54e89ba fix(equipmentset): drop a held set on right-click
The world input hook's right-press clear tests the engine's payload
globals rather than the cursor type, and an equipment set has none, so
the press fell through. Co-hook it to clear and consume for our type.
2026-10-26 17:11:00 -05:00
Brues 35b8eaa376 feat(tooltip): grow the line pool on demand, as 4.3.4 does
Replace the scan-time grow to a fixed 60 lines with 4.3.4's
CGTooltip::AddLine grow: co-hook the add-line gate (FUN_00530270, which
every Lua method and native builder goes through) and, when the line
about to be added takes the last slot, build the next TextLeft/Right
pair and append it through the engine's own AddFontStrings body
(FUN_00529940). The pool keeps one spare line after every add, with no
cap, and tooltips no longer pre-build 30 unused line pairs.

New lines start hidden (the clear only hides lines below numLines) and
take the engine's line gap constant instead of a literal.
2026-10-26 16:44:34 -05:00
Brues f5082cedee fix(texture): keep NPOT out of the recycle caches, allow sides below 8
The dimension gate routes NPOT textures around the recycle pool on
allocation, but the free path filed them anyway: TexturePoolRelease
buckets by each side's power-of-two factor with no POT test, so a freed
48x48 landed in the 32x32 bucket and was handed to the next 32x32
request. Pool on free exactly what the allocator pools (POT, no larger
than the pool max), as 4.3.4's GxTexReusable does.

The decoded-buffer cache (TextureAllocMips / TextureFreeMips) buckets
the same way, so an NPOT BLP on the model/character cache path could be
given a smaller cached buffer and decode past it. NPOT sizes now take
the engine's own uncached alloc and free.

The create validator's 8px minimum is lowered to 1, so 1x1 and 4x4
textures load. A failed D3D create leaves the texture blank.
2026-10-26 16:31:10 -05:00
Brues cca8ff3c55 feat(frame): pass 3.3.5's click down and enter/leave motion
3.3.5 runs OnClick/PreClick/PostClick as (self, button, down) and
OnEnter/OnLeave as (self, motion). 1.12's runners carry only
%d/%u/%f/%s, and neither value reaches the script.

Frame::ScriptArgs gains AutoTrailingBool: a provider supplies one boolean
that becomes the last argN and positional argument of a run on the
positional path. Handlers that declare no parameters keep vanilla's
globals.

- down: the click vmethod gets (mask, fromScript) and never learns press
  or release, but its callers do. The six 3.3.5 maps onto -- the press
  handler (down = 1), the release handler (0) and Script_Click's four
  name branches (StringToBOOL(L, 3, 0), as 3.3.5's Click reads it) --
  each `call [edx+0x94]`, now replaced with a thunk that records
  {button, down} and jumps through the same slot. The click detour takes
  the record for its own button and offers it to that button's
  OnClick/PreClick/PostClick runs.
- motion: the mouse-focus pass 0x007660D0 is 3.3.5's OnMouseMove. Its
  re-pick passes the context's saved event (+0x1104), the same test
  3.3.5 uses for motion. A co-hook records {context, old focus, motion}
  for the pass; its own leave (old focus) and enter (new focus at +0x7C)
  get motion, and every other enter/leave gets false.

Verified in-game: enter/leave on a mouse move give true, and a hide and
show under a still cursor give false. A press and release on a button
registered for both give down true then false, Click("RightButton",
true) gives true, and Click() gives false.
2026-10-26 16:04:23 -05:00
Brues b4256406f5 fix(frame): match 3.3.5's script runner on arg count, global saves and event
Three differences from 3.3.5's FrameScript_Execute (0x00819EA0):

- No argument limit. The engine runner sets only arg1..arg19 (0x704F10
  stops at the 19th spec, 0x704F73), and our parser copied that limit for
  the positional args too. 3.3.5 sets every argN. The parser is replaced by
  a cursor over the format, and a fire with more than 19 values takes the
  reimplemented path even for a handler that declares no parameters.
  argN names come from snprintf; the old two-digit builder misnamed arg100
  onward.
- Raw saves. The old this/argN values and the positional `event` are read
  with rawget, as 3.3.5 does, so a `_G` __index (a strict-globals guard)
  no longer runs for an unset argN.
- `event` per handler. The engine dispatchers set `event` once for the
  whole dispatch, so a handler that assigned the global changed it for
  every listener after it. Each OnEvent run now saves `event` before the
  handler and restores it after, on both runners and RunWithValues, as
  3.3.5 does per handler. No new hooks; off while SetModernScriptArgs is
  false.

Verified in-game: two listeners that each print `event` and then set it
both print PLAYER_TARGET_CHANGED, and a `_G` __index sees no `this` read
from a modern handler's run.
2026-10-26 15:51:01 -05:00
Brues 2adabbbd70 fix(frame): pass positional script args to C-function handlers
The arity gate on Frame::ScriptArgs sent every C closure except the
HookScript wrapper through the original no-arg runner, on the premise
that C functions never read positional args. Frame methods do: they take
`self` from argument 1 and raise "Attempt to find 'this' in non-table
object" without it, never falling back to the `this` global. So the
common modern idiom

    f:SetScript("OnDragStart", f.StartMoving)

errored, where 3.3.5's FrameScript_Execute passes (self, ...) to every
handler.

Treat any C closure as wanting args. This cannot regress a working
vanilla addon that binds a frame method, since that call already errored.
The Lua-closure gate from a4d6290 stays as is.

Verified in-game: a frame with OnShow = f.Hide stays hidden, a param-less
vanilla handler still sees this/arg1, and OnDragStart/OnDragStop bound to
f.StartMoving/f.StopMovingOrSizing drag the frame.
2026-10-26 15:41:48 -05:00
brues-code 42ed0d27c7 Port the later clients' Cooldown API (#112)
* Add retail Cooldown methods and OnCooldownDone

Port the post-4.3.4 Cooldown API onto the native CGCooldown frame:
Clear, Pause/Resume/SetPaused/IsPaused, GetCooldownTimes,
GetCooldownDuration, GetCooldownDisplayDuration, SetCooldownDuration,
SetCooldownFromExpirationTime, the modRate argument, SetDrawSwipe,
SetDrawBling, SetSwipeColor, SetEdgeColor, the drawSwipe/drawBling XML
attributes, and the OnCooldownDone script (via Frame::ExtraScripts).

Behavior follows retail probes, not the generated doc strings: the
getters report the end flash once the sweep ends, modRate scales the
flash and the display duration but not the sweep, and a reverse
cooldown flashes and hides like any other.

Add CooldownFrame_Set, CooldownFrame_Clear and
CooldownFrame_SetDisplayAsPercentage to the embedded addon.

* List the new Cooldown methods in the README table

* Add Cooldown swipe, edge and bling textures

SetSwipeTexture, SetEdgeTexture and SetBlingTexture, and the
<SwipeTexture>, <EdgeTexture> and <BlingTexture> XML elements, split
4.3.4's star texture into three slots. An empty file is the engine's
solid texture tinted by the slot colour, and Interface\Cooldown\edge
maps to the built-in edge texels. A setter without r, g, b keeps the
colour, as retail does. Slots left unset get 4.3.4's textures at the
first SetCooldown, so the defaults stay 4.3.4's. The swipe's texcoords
now map its positions over the frame so a custom texture lines up.

* Add Cooldown rotation

SetRotation / GetRotation and the XML rotation attribute (degrees) move
where the sweep starts, clockwise from the top, as retail does. The
sweep is now one polygon along the frame's outline in place of 4.3.4's
four octant functions, with the same timing (each half side an eighth
of the duration) and, at rotation 0, the same shape. The one difference
is 4.3.4's reverse octant 1, which drew one triangle twice.

* Add Cooldown countdown numbers

Every Cooldown gets a countdown font string (FRIZQT__ 16 OUTLINE,
centred), shown while retail's countdownForCooldowns CVar is 1; the
CVar defaults to 0, so the default look stays 4.3.4's. The text
follows retail: seconds, then m:ss below the abbreviation threshold,
then whole minutes, hours and days rounded up, and nothing for
cooldowns no longer than the minimum duration or during the flash.
Adds SetHideCountdownNumbers / GetHideCountdownNumbers,
GetCountdownFontString, SetCountdownAbbrevThreshold /
GetCountdownAbbrevThreshold, SetMinimumCountdownDuration /
GetMinimumCountdownDuration and the hideCountdownNumbers and
minimumCountdownDuration XML attributes.

* Add the Cooldown countdown milliseconds threshold

SetCountdownMillisecondsThreshold / GetCountdownMillisecondsThreshold:
below the threshold the numbers show tenths, rounded up, as retail
does (threshold 5: 4.973 shows 5.0, 3.24 shows 3.3, 0.039 shows 0.1).
The default is 0, so the numbers stay whole seconds.

* Fix the Cooldown review findings

- A zero-length timer (a new frame, SetCooldown(t, 0)) hides without the
  bling flash or OnCooldownDone, instead of flashing and reporting
  (now, 1000) from the getters.
- Countdown text rounds up with integer math (700 ms read "0.8") and
  picks its unit on the rounded-up seconds (59.4 s read "60", not "1:00").
- SetMinimumCountdownDuration and minimumCountdownDuration clamp to the
  uint32 range.
- The edge texcoord rotation uses the guarded done fraction (0/0 gave NaN).
- Texture slots are one array with per-slot tag, default file and draw
  layer (Offsets::DRAWLAYER_*), replacing the ternaries and bare 2/3.
- The header lists where the state machine follows retail over 4.3.4.

edge.blp is now served to every texture load by texture/Builtin.cpp, a
hook on TextureCreateFromBlp that falls back to the DLL's texels only when
SFile has no such file; TextureCreate caches and ref-counts it like a file.
Cooldown loads it by path and drops its edge special case.

Frame::Region::CreateTexture / CreateFontString replace the hand-rolled
pool-alloc + ctor copies in Cooldown, LinePool, BarTexture, SoftTargetIcon
and InlineTexturePool.
2026-10-26 15:21:34 -05:00
Brues 0d19deaee0 Add ItemHasRange global and C_Item.ItemHasRange
Global returns 1/nil like 3.3.5; namespaced form returns a bool. Both resolve the item to its on-use spell and use the shared SpellIDHasRange engine check. Verified in-game: ItemHasRange(1251) -> 1, C_Item.ItemHasRange(6948) -> false.
2026-10-26 00:38:59 -05:00
Brues abd5367533 feat(nameplate): exempt plate subtrees from inherited alpha, add selected/non-selected alpha CVars
Default nameplates and anything parented under them keep vanilla alpha semantics, so third-party overlays like pfUI's are never dimmed by the engine fade or multiplied by a parent. The plate fade emulates the original setter but spares children with an explicitly-set alpha. nameplateNotSelectedAlpha (0.5) and nameplateSelectedAlpha (1) resolve the fade's legacy 0x7F/0xFF writes; both defaults are byte-identical to vanilla.
2026-10-25 19:54:51 -05:00
Brues 3e83827ae9 fix(spell): Route spell has-range gate through the engine's GetMinMaxRange
SpellIDHasRange now calls 0x6E3480 and tests both bounds against the ActionHasRange epsilon instead of reading raw SpellRange.dbc values, covering Attributes & 0x404 spells, melee reach folding, and the stationary/item/modifier bonuses. Centralizes the SpellRange record min/max offsets in Offsets.h (used by GetSpellInfo). Updates API.md (SpellHasRange mechanism, Fireball 35yd).
2026-10-25 02:30:14 -05:00
Brues 443c78c453 Backport TOTAL_MEM_*_ABBR and ADDON_MEM_*_ABBR locale strings 2026-10-25 00:31:22 -05:00
Brues a97909e36c Route non-Turtle spell links to SetItemRef; one SetItemRef hook
Turtle's SpellChatLinks.lua overrides ChatFrame_OnHyperlinkShow and
handles spell: links itself, so those clicks never reach SetItemRef --
and its spell:(%d+):(%w+):(%w+) pattern silently drops anything shaped
differently, including our own spell:<id>:0 links (GetSpellLink).
Wrap ChatFrame_OnHyperlinkShow to route those back through SetItemRef
(the Spell handler shows them); Turtle-shaped links pass through.

Also folds TradeSkillLink's second SetItemRef override into a
LinkUtil.RegisterLinkHandler, leaving exactly one override to chain.
Adds a GetSpellInfoFromHyperlink helper.
2026-10-24 23:17:00 -05:00
Brues 89f8932b35 Add FlashClientIcon([briefly]) system API
Flashes the client taskbar button via FlashWindowEx on the engine's own
game window (OsGuiGetWindow(0), 0x00435C30). Default flashes until the
client is focused; briefly=true flashes three times. No-op while WoW is
foreground. Documented in docs/API.md + README reference table.
2026-10-24 18:15:27 -05:00
Brues 829b1cfed3 fix(bindings): hide a key's modifiers only around direct actions
v1.16 ran 4.3.4's key-state override around every key binding, so a Lua
binding on SHIFT-E saw IsShiftKeyDown() as nil inside its own handler.
1.12 addons that bind modified keys and read the modifier there
(Puppeteer) cast their unmodified action instead.

The override now covers only SPELL / ITEM / MACRO / CLICK, the commands
1.12 never had; Lua bindings see the held keys, as before v1.16.
2026-10-24 09:41:00 -05:00
Brues 33804bb66d fix(playercache): load account settings before reading the cache toggle
The name hook, Lookup, LookupByName and Remember read g_enabled
directly, so the cache stayed off all session unless something else
loaded the account settings first. Only an IsEnabled/SetEnabled call
or a custom GlueXML's GetSavedCharacterOrder did, so with stock
GlueXML the persistent cache never saved or answered until the options
panel was opened. Route every gate through IsEnabled().
2026-10-23 20:57:25 -05:00
Brues 80d3e5d8d7 refactor(playercache): drop the visible-object scan, import the engine cache on enable
The scan read names through CGUnit_C::GetUnitName, whose only filled
rows come from the NameCache writer the module already hooks, so it
re-read data the hook had mirrored. The one real gap was rows the
engine resolved while the cache was off; enabling now imports them by
walking the engine NameCache (Unit::Identity::ForEachCachedPlayer).

Removes C_PlayerCache.SetScanEnabled/IsScanEnabled, the
NameCacheScanEnabled setting and the "Scan nearby players" option.
2026-10-23 20:54:05 -05:00
Brues bfcd95504e fix(addon options) Correct header 2026-10-23 20:37:25 -05:00
Brues bea82687a4 refactor(addon options) Move Player Cache settings to their own subcategory 2026-10-23 20:36:47 -05:00
Brues 63078578b4 refactor(game): route every SStrCmpI call through Game::StrCmpI
Twelve files each declared their own SStrCmpI function-pointer type and
cast FUN_SSTR_CMP_I at the call. One wrapper in Game now owns the
__stdcall signature (a __cdecl copy drifts ESP by 12 per call) and the
"whole string" length default. Tooltip::Hyperlink's prefix test moves
from the CRT _strnicmp to the same wrapper, which the engine's SStrCmpI
calls anyway.
2026-10-23 20:01:37 -05:00
Brues c7bb90895f feat(playercache): resolve names through the engine name cache
C_PlayerCache.GetPlayerInfoByName now searches the engine's session name
cache (every player it has named since world entry) after the friends
list, so past chat senders resolve with the persistent cache off. The
engine has no by-name lookup, so Unit::Identity::GuidForCachedName walks
the cache's node list the way NameCacheSave (0x0055EC70) does.

The persistent NameCache now matches names ignoring ASCII case, the same
rule as the engine's SStrCmpI, so every lookup step behaves alike.
2026-10-23 19:58:23 -05:00
Brues cd7f830697 feat(target): highlight the soft-interact object in reach
softinteract glows like the mouseover object while the player can interact
with it right now, through the engine's own CGObject_C::SetLight/ClearLight
on a light bit of its own (bit 2, past the target's 0 and the mouseover's 1).
2026-10-23 18:39:53 -05:00
Brues 557dc2f454 docs(readme): merge the soft-target rows and add a combat log row 2026-10-23 17:43:15 -05:00
Brues 6c5269e3e6 feat(profiling): add per-addon memory and CPU usage
Backport UpdateAddOnMemoryUsage / GetAddOnMemoryUsage and the script
profiling family (scriptProfile, GetFunctionCPUUsage, UpdateAddOnCPUUsage,
GetAddOnCPUUsage, GetFrameCPUUsage, GetEventCPUUsage, GetScriptCPUUsage,
ResetCPUUsage), mirroring 3.3.5's engine mechanism.

- Gc::Hooks owns the GC lifecycle hooks (luaC_link, newlstr,
  luaS_newudata, freeobj); Gc::Tag stamps each new object with the
  engine's context word (3.3.5's lua_tainted rule) and sums sizes with
  the engine's own free sizes.
- lua_gctag pins: the addon load window (two call patches in
  FUN_ADDON_LOAD_ONE) and template application (engine single-name
  sites + Xml::Inherits lists).
- Profile::Cpu keeps 3.3.5's per-closure Profile records through Lua's
  own call/return hook, installed only when scriptProfile is 1; tail
  calls, error unwinds and coroutines handled.
- ScriptArgs observers time every handler run; signals counted at each
  dispatcher (call-site redirect inside FUN_FIRE_EVENT, not a MinHook).
- Frame::ScriptSlots enumerates a frame's script slots without creating
  them, for GetFrameCPUUsage.
- Add the unordered_dense submodule (v5.3.2); share ResolveRecord via
  addons/Registry; fix the AddOnEntry inline-name comments.
2026-10-23 15:31:39 -05:00
Brues b5aed30a5d feat(chat): add GetCurrentChatEvent
Returns the event name and arguments of the CHAT_MSG_* event being fired,
read from the fire itself rather than the event/arg globals, which 1.12's
ChatFrame_OnEvent depends on and any earlier handler or hook can overwrite.
Nested chat events restore the outer payload when they return.
2026-10-23 03:05:09 -05:00
Brues 9755cd3e09 feat(gossip): fire DYNAMIC_GOSSIP_POI_UPDATED and document the POI API
Diff the gossip marker's x, y and expiry once per frame: each set
rewrites them and both clears (10 yards, 480 s) zero them. The map
rebuild that every change runs also runs on each map view change, so it
is not a usable trigger on its own. Add the two POI functions and the
event to README.md and docs/API.md.
2026-10-23 01:44:22 -05:00
Brues 604d87b9fc feat(gossip): add C_GossipInfo.GetPoiForUiMapID and GetPoiInfo
Read the gossip marker from static blip record 1, where the
SMSG_GOSSIP_POI handler stores it, and place it on a map with the world
map's own test (0x004A7360): same map id and inside 0..1. The packet
carries no POI id and the engine holds one marker, so its id is 1.
2026-10-23 01:37:53 -05:00
Brues 6b0a74ec4b fix(gossip): gate C_GossipInfo on the greeting-pending flag and walk visible options in SelectOptionByIndex
The gossip GUID is latched before the greeting resolves, so GUID != 0 alone
served a window that had not shown yet, with the previous NPC's text. Honor
the engine's pending flag (0xBBB670), the same one its option selector checks.

SelectOptionByIndex used orderIndex - 1 as the storage slot; it now skips
hidden slots the way GetOptions numbers them.
2026-10-23 01:30:06 -05:00
Brues 165ccf3e9c refactor(lua): share the engine's flag readers as Game::Lua::ToFlag and Game::StringToFlag
Twenty modules each declared their own typedef or wrapper for
FUN_LUA_TO_BOOLEAN_LOOSE (4.3.4's StringToBOOL(L, index, default)) or
FUN_XML_ATTR_TO_BOOL (StringToBOOL(const char *)). Route them all
through one helper each.

frame/Cooldown.cpp had a C++ copy of both that read unknown attribute
text as true; the engine's reader, which 4.3.4's XML loader uses, reads
it as false. SetReverse/SetDrawEdge now also truncate numbers like
every other 1.12 flag method.
2026-10-23 01:15:38 -05:00
Brues ce4f80518f fix(tooltip): match 4.3.4's SetSpellByID and AddSpellByID
Take (spellID [, isPet [, showSubtext]]), pass the spell's remaining
cooldown to the engine builder as 1.12's SetSpell does, and return 1
when the cooldown line shows, else nil. The rank is now off by default
(showSubtext turns it on), and a bad ID raises 4.3.4's
"Invalid spell ID in %s:SetSpellByID".

AddSpellByID rewrites the builder's "Next rank:" header through the
engine's add-line, so showSubtext works when appending, and leaves a
recipe's created-item tooltip untouched.
2026-10-23 01:07:00 -05:00
Brues 3009c6d8d2 fix(addon): splice the AddOn Options menu button on first show
Addons that load after us insert their own game menu buttons by taking
Key Bindings' anchor too. ShaguTweaks anchors "Advanced Options" under
Interface Options, which put it on top of our button. Waiting for the
menu's first show lets every addon place its buttons first, so ours goes
right above Key Bindings wherever it ended up.
2026-10-22 23:05:22 -05:00
Brues b83bb5b52b feat(frame): draw the Cooldown sweep natively, as 4.3.4's CGCooldown
A Cooldown was vanilla's indicator Model with CooldownFrameTemplate loaded
into it, so SetReverse and SetDrawEdge stored flags that changed nothing,
and every cooldown paid a Model's per-frame cost.

Cooldown is now a CSimpleFrame subclass built the way CGCooldown::Create
builds it: one SMemAlloc block, the engine's frame constructor, and copies
of CSimpleFrame's two vtables with 4.3.4's overrides in their 1.12 slots
(update, layer emit, alpha, size change, LoadXML, identity, destructor).
It runs 4.3.4's sweep / flash / reverse state machine and queues the star
fan, flash and edge as layer-batch strips animated in place.

- The five methods live in a class method hash of their own, so only a
  Cooldown has them; it is released after CGGameUI's unregister pass.
- GetObjectType / GetFrameType answer through the engine's virtuals; the
  Lua re-registrations are gone.
- star4.blp ships with 1.12; edge.blp does not, so its 16x16 image is
  created in memory through TextureCreateWithCallback.
- A Cooldown inheriting the stock Model CooldownFrameTemplate applies
  4.3.4's template (setAllPoints, hidden); reverse / drawEdge XML
  attributes are read.
- CooldownFrame_SetTimer calls SetCooldown on a Cooldown and keeps 1.12's
  body for the Models stock buttons use.
- Mask.cpp's path loader moves to texture/Load; frame/RenderBatch queues
  quads into a layer batch.

Verified in-game: sweep, flash and hide; reverse with the edge line;
stock action-button cooldowns unchanged; repeated /reload.
2026-10-22 22:15:17 -05:00
462 changed files with 45252 additions and 4055 deletions
+1 -1
View File
@@ -47,4 +47,4 @@
CLAUDE.md
/AddOns/!!!ClassicAPI/.classicapi-dev
/src/embed/AddOns/*/.classicapi-dev
+3
View File
@@ -7,3 +7,6 @@
[submodule "external/tinycbor"]
path = external/tinycbor
url = https://github.com/intel/tinycbor.git
[submodule "external/unordered_dense"]
path = external/unordered_dense
url = https://github.com/martinus/unordered_dense.git
-70
View File
@@ -1,70 +0,0 @@
## Interface: 11200
## Title: !!!ClassicAPI
## Notes: Library to emulate aspects of the modern WoW API on 1.12.1 / Lua 5.0.
## Version: DEV
## Author: Brues
## X-Website: https://github.com/brues-code/ClassicAPI
locales\locales.xml
FrameXML\InterfaceOptions.xml
FrameXML\InterfaceOptions.lua
SecureTemplates.xml
Compat.lua
Util\Print.lua
Util\Mixin.lua
Util\Color.lua
Util\Constants.lua
Util\TableUtil.lua
Util\MathUtil.lua
Util\PixelUtil.lua
Util\FrameUtil.lua
Util\FormattingUtil.lua
Util\TimeUtil.lua
Util\CallbackRegistry.lua
Util\GlobalCallbackRegistry.lua
Util\ColorUtil.lua
Util\ItemLocation.lua
Util\PlayerLocation.lua
Util\EventUtil.lua
Util\ItemUtil.lua
Util\AddOnCompat.lua
Util\FunctionUtil.lua
Util\CacheUtil.lua
Util\Pools.lua
Util\FrameWatcher.lua
Util\TimedCallback.lua
Util\EquipmentManager.lua
Util\PlayerUtil.lua
Util\SlashCommandsRegistry.lua
Util\LinkUtil.lua
Util\TradeSkillLink.lua
Util\IconDataProvider.lua
Util\GameTooltip.lua
Util\Rectangle.lua
Util\Vector2D.lua
Util\Vector3D.lua
Util\Vector4D.lua
Util\UiMapPoint.lua
Util\UIParent.lua
Util\ActionButton.lua
Util\WorldMap.lua
Util\AuraDurationModifiers.lua
Util\AuraUtil.lua
Util\SecureUnitMenu.lua
Util\TextureUtil.lua
Util\MacroOptions.lua
Util\SecureStateDriver.lua
Util\MacroIconSelection.lua
Options\Panel.lua
APIDocumentation\BaseAPIMixin.lua
APIDocumentation\FieldsAPIMixin.lua
APIDocumentation\FunctionsAPIMixin.lua
APIDocumentation\SystemsAPIMixin.lua
APIDocumentation\TablesAPIMixin.lua
APIDocumentation\EventsAPIMixin.lua
APIDocumentation\APIDocumentation.lua
APIDocumentation\APIDocumentation_Bootstrap.lua
-217
View File
@@ -1,217 +0,0 @@
-- AoE Loot Test
--
-- /aoeloot opens a picker window listing every item in every lootable
-- corpse within click range. Clicking a row hands the (guid, itemID)
-- pair to C_Loot.LootUnitItem and re-scans so the picker refreshes
-- with whatever's left.
--
-- Known visual: each scan opens / closes loot windows in rapid
-- succession, so the Blizzard LootFrame flickers during the scan. This
-- addon intentionally doesn't suppress that — the goal is to exercise
-- the C++ surface, not to ship polished UI.
local MAX_ROWS = 24
local RESCAN_DELAY = 0.3 -- seconds; lets LootUnitItem complete before re-scan
-- ---------- UI scaffolding ----------
-- Frame geometry. Height is recomputed dynamically per scan so the
-- window only takes up as much screen real estate as the result count
-- needs — see `ResizeForRows`.
local FRAME_WIDTH = 320
local HEADER_HEIGHT = 64 -- title + status + insets; minimum frame height
local ROW_HEIGHT = 22
local frame = CreateFrame("Frame", "AoELootTestFrame", UIParent)
frame:SetWidth(FRAME_WIDTH)
frame:SetHeight(HEADER_HEIGHT)
frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
frame:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
tile = true, tileSize = 32, edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
})
frame:SetMovable(true)
frame:EnableMouse(true)
frame:RegisterForDrag("LeftButton")
-- Vanilla 1.12 fires script handlers with the frame set as the global
-- `this`, not as a `self` arg to the function. Passing `frame.StartMoving`
-- directly (the modern shortcut) breaks because the C method has no
-- `self` to operate on. Wrap in closures so `this:method()` resolves
-- correctly inside the handler.
frame:SetScript("OnDragStart", function() this:StartMoving() end)
frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
frame:Hide()
local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
title:SetPoint("TOP", frame, "TOP", 0, -16)
title:SetText("AoE Loot")
local status = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
status:SetPoint("TOPLEFT", frame, "TOPLEFT", 18, -36)
status:SetText("")
local closeBtn = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
closeBtn:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -6, -6)
-- ---------- Row pool ----------
local rows = {}
local function CreateRow(parent, idx)
local row = CreateFrame("Button", nil, parent)
row:SetWidth(290)
row:SetHeight(20)
row:SetPoint("TOPLEFT", parent, "TOPLEFT", 15,
-54 - (idx - 1) * ROW_HEIGHT)
local icon = row:CreateTexture(nil, "ARTWORK")
icon:SetWidth(18); icon:SetHeight(18)
icon:SetPoint("LEFT", row, "LEFT", 0, 0)
row.icon = icon
local text = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
text:SetPoint("LEFT", icon, "RIGHT", 6, 0)
text:SetPoint("RIGHT", row, "RIGHT", 0, 0)
text:SetJustifyH("LEFT")
row.text = text
local hl = row:CreateTexture(nil, "HIGHLIGHT")
hl:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight")
hl:SetBlendMode("ADD")
hl:SetAllPoints(row)
-- Tooltip on hover. The engine's native `SetHyperlink` does a
-- literal `SStrCmpI` of the input's first 5 chars against the
-- string `"item:"` at `.rdata` VA `0x00842D60` — anything that
-- starts with `|c...` (full link with color) or `|Hitem` (full
-- link without color) fails the prefix check with "Unknown link
-- type". Only the bare payload form survives.
--
-- We extract the payload from the engine's full link rather than
-- composing `item:ID:0:0:0` ourselves, so per-instance fields
-- (enchant / random-suffix / unique) survive round-trip and
-- "Stringy Wolf Meat of the Bear" tooltips correctly instead of
-- collapsing to the un-suffixed itemID.
row:SetScript("OnEnter", function()
if this.payload then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetHyperlink(this.payload)
GameTooltip:Show()
end
end)
row:SetScript("OnLeave", function() GameTooltip:Hide() end)
row:Hide()
return row
end
for i = 1, MAX_ROWS do
rows[i] = CreateRow(frame, i)
end
-- ---------- Render ----------
-- Tail of a GUID string — last 4 hex chars of the low dword. Lets the
-- user tell apart two rows of "Stringy Wolf Meat x1" coming from
-- different corpses, without occupying a whole column with the full
-- 16-digit GUID.
local function ShortGuidSuffix(guid)
return "[.." .. string.sub(guid, -4) .. "]"
end
-- Extract the `item:ID:enchant:suffix:unique` payload out of the
-- engine's full link string (`|cff...|Hitem:...|h[Name]|h|r`).
-- Vanilla `SetHyperlink` requires the input to start literally with
-- `"item:"` — so we can't pass the full link, but we also can't drop
-- the per-instance fields (random suffix is what distinguishes "of
-- the Bear" from "of the Eagle" on otherwise identical itemIDs).
-- Returns the payload, or nil if extraction fails.
local function PayloadFromLink(link)
if not link then return nil end
return string.match(link, "|H(item:[^|]+)|h")
end
local function ClearRows()
for i = 1, MAX_ROWS do
rows[i]:Hide()
rows[i]:SetScript("OnClick", nil)
rows[i].payload = nil
end
end
local function PopulateRows(results)
local idx = 0
for _, corpse in ipairs(results) do
for _, item in ipairs(corpse.items) do
idx = idx + 1
if idx > MAX_ROWS then break end
local row = rows[idx]
local name, _, _, _, _, _, _, _, icon = GetItemInfo(item.itemID)
row.icon:SetTexture(icon or "Interface\\Icons\\INV_Misc_QuestionMark")
local label = name or ("Item #" .. item.itemID)
if item.count and item.count > 1 then
label = label .. " x" .. item.count
end
label = label .. " " .. ShortGuidSuffix(corpse.guid)
row.text:SetText(label)
row.payload = PayloadFromLink(item.link) -- for OnEnter tooltip
local guid, itemID = corpse.guid, item.itemID
row:SetScript("OnClick", function()
C_Loot.LootUnitItem(guid, itemID)
C_Timer.After(RESCAN_DELAY, function()
if frame:IsShown() then C_Loot.ScanNearbyLoot() end
end)
end)
row:Show()
end
if idx > MAX_ROWS then break end
end
for i = idx + 1, MAX_ROWS do rows[i]:Hide() end
local shown
if idx == 0 then
status:SetText("Nothing lootable in range.")
shown = 0
elseif idx > MAX_ROWS then
status:SetText(MAX_ROWS .. "+ items (showing first " .. MAX_ROWS .. ")")
shown = MAX_ROWS
else
status:SetText(idx .. " item(s) available")
shown = idx
end
frame:SetHeight(HEADER_HEIGHT + shown * ROW_HEIGHT)
end
-- ---------- Scan trigger ----------
local function StartScan()
status:SetText("Scanning...")
ClearRows()
frame:SetHeight(HEADER_HEIGHT) -- collapse while we wait for results
if not C_Loot.ScanNearbyLoot() then
status:SetText("Scan busy or not in world.")
end
end
-- ---------- Event wiring ----------
local evt = CreateFrame("Frame")
evt:RegisterEvent("LOOT_SCAN_COMPLETED")
evt:SetScript("OnEvent", function()
-- Race guard: a delayed scan may complete after the user closed
-- the picker. Just drop the result rather than re-showing.
if not frame:IsShown() then return end
PopulateRows(C_Loot.GetLastScanResults())
end)
-- ---------- Slash command ----------
RegisterNewSlashCommand(function()
if frame:IsShown() then
StartScan() -- already open → just refresh
else
frame:Show()
StartScan()
end
end, "aoeloot", "aoe")
-7
View File
@@ -1,7 +0,0 @@
## Interface: 11200
## Title: AoE Loot Test
## Notes: Test bed for ClassicAPI's C_Loot.ScanNearbyLoot / LootUnitItem. /aoeloot
## Dependencies: !!!ClassicAPI
## Version: DEV
AoELootTest.lua
-335
View File
@@ -1,335 +0,0 @@
------------------------------------------------------------------------------
-- TableInspector.lua
--
-- Vanilla 1.12 backport of the modern Blizzard_TableInspector UX,
-- scoped to the "minimal" feature set: one navigable window with a
-- breadcrumb showing the current path, a scrollable list of (key,
-- value) rows for the current table, and click-to-drill / Back-to-pop
-- navigation through sub-tables.
--
-- Trade-offs vs the modern source:
-- * No DataProvider / ScrollBox infrastructure — we use the same
-- fixed-row + slider pattern EventTraceFrame uses (vanilla
-- FauxScrollFrame works too but we already validated the slider
-- approach in this addon).
-- * No anchor / attribute providers, no live polling — open once
-- against the current snapshot.
-- * No multi-tab or pinning — one window, navigate via Back.
--
-- API:
-- TableInspector_Show(tbl, label) -- open with `tbl` as root, `label` shown in breadcrumb
-- TableInspector_Push(tbl, label) -- drill in (called by row click)
-- TableInspector_Back() -- pop one level
-- TableInspector_Refresh() -- re-read the current level's live table (scroll kept)
-- TableInspector_Close() -- hide the window
-- /tinspect <luaexpr> -- evaluate `<luaexpr>` as Lua and open if result is a table
------------------------------------------------------------------------------
TABLE_INSPECTOR_ROW_HEIGHT = 16;
TABLE_INSPECTOR_NUM_ROWS = 22;
local _TableInspectorFrame;
-- Navigation stack — `{ {tbl=..., label=...}, ... }`. The last entry
-- is the active view. Push when drilling in, pop on Back.
local _navStack = {};
------------------------------------------------------------------------------
-- Key + value formatting
------------------------------------------------------------------------------
-- Returns three lists: numeric keys (sorted ascending), string keys
-- (sorted), and other-type keys (booleans, tables-as-keys, etc.).
-- The other-type bucket stays unsorted — there's no canonical order
-- across types and these are rare in practice.
local function _SortedKeys(tbl)
local nums, strs, others = {}, {}, {};
for k in pairs(tbl) do
local t = type(k);
if (t == "number") then
table.insert(nums, k);
elseif (t == "string") then
table.insert(strs, k);
else
table.insert(others, k);
end
end
table.sort(nums);
table.sort(strs);
return nums, strs, others;
end
-- Returns (displayText, valueType, isDrillable). `isDrillable` is true
-- only for tables (the only type that can be navigated into).
local function _FormatValue(v)
local t = type(v);
if (t == "string") then
-- Truncate very long strings to keep rows scannable; user can
-- always tooltip-hover for the full content if needed.
if (string.len(v) > 80) then
return string.format('"%s..."', string.sub(v, 1, 77)), "string", false;
end
return string.format('"%s"', v), "string", false;
elseif (t == "number") then
return tostring(v), "number", false;
elseif (t == "boolean") then
return v and "true" or "false", "boolean", false;
elseif (t == "table") then
return tostring(v), "table", true;
elseif (t == "function") then
return tostring(v), "function", false;
elseif (t == "userdata") then
return tostring(v), "userdata", false;
end
return tostring(v), t, false;
end
local _TYPE_COLORS = {
["string"] = { 1.00, 0.75, 0.40 },
["number"] = { 0.60, 0.85, 1.00 },
["boolean"] = { 0.70, 0.70, 1.00 },
["table"] = { 0.60, 1.00, 0.60 },
["function"] = { 1.00, 0.60, 0.60 },
["userdata"] = { 1.00, 0.60, 0.60 },
};
local function _ValueColor(t)
local c = _TYPE_COLORS[t];
if (c) then return c[1], c[2], c[3]; end
return 1, 1, 1;
end
local function _FormatKey(k)
local t = type(k);
if (t == "string") then return k; end
if (t == "number") then return string.format("[%d]", k); end
return string.format("[%s]", tostring(k));
end
------------------------------------------------------------------------------
-- View state — manages the flattened (key, value) list for the active
-- table. Rebuilt on push / pop, not on every Update tick.
------------------------------------------------------------------------------
-- Cached array of (key, value) tuples for the active table, in the
-- sort order returned by _SortedKeys. Re-derived on every navigation
-- change so the rows can be rendered by simple `entries[scroll + i]`
-- indexing instead of walking `pairs()` mid-frame.
local _entries = {};
local function _RebuildEntries()
_entries = {};
local top = _navStack[table.getn(_navStack)];
if (not top or type(top.tbl) ~= "table") then return; end
local nums, strs, others = _SortedKeys(top.tbl);
for i = 1, table.getn(nums) do
local k = nums[i];
table.insert(_entries, { key = k, value = top.tbl[k] });
end
for i = 1, table.getn(strs) do
local k = strs[i];
table.insert(_entries, { key = k, value = top.tbl[k] });
end
for i = 1, table.getn(others) do
local k = others[i];
table.insert(_entries, { key = k, value = top.tbl[k] });
end
end
local function _Breadcrumb()
local parts = {};
for i = 1, table.getn(_navStack) do
table.insert(parts, _navStack[i].label or "?");
end
return table.concat(parts, " > ");
end
------------------------------------------------------------------------------
-- Public API
------------------------------------------------------------------------------
function TableInspector_Show(tbl, label)
if (type(tbl) ~= "table") then
DEFAULT_CHAT_FRAME:AddMessage(
"|cffff8888TableInspector: argument is not a table|r");
return;
end
_navStack = { { tbl = tbl, label = label or tostring(tbl) } };
_RebuildEntries();
if (_TableInspectorFrame) then
_TableInspectorFrame:Show();
TableInspectorFrame_Update();
end
end
function TableInspector_Push(tbl, label)
if (type(tbl) ~= "table") then return; end
table.insert(_navStack, { tbl = tbl, label = label or tostring(tbl) });
_RebuildEntries();
-- Reset scroll to top so the user starts at the beginning of the
-- newly-drilled table.
local scroll = getglobal("TableInspectorFrameScroll");
if (scroll) then scroll:SetValue(0); end
TableInspectorFrame_Update();
end
function TableInspector_Back()
if (table.getn(_navStack) <= 1) then
TableInspector_Close();
return;
end
table.remove(_navStack);
_RebuildEntries();
local scroll = getglobal("TableInspectorFrameScroll");
if (scroll) then scroll:SetValue(0); end
TableInspectorFrame_Update();
end
function TableInspector_Refresh()
local top = _navStack[table.getn(_navStack)];
if (not top or type(top.tbl) ~= "table") then return; end
_RebuildEntries();
TableInspectorFrame_Update();
end
function TableInspector_Close()
if (_TableInspectorFrame) then _TableInspectorFrame:Hide(); end
_navStack = {};
_entries = {};
end
------------------------------------------------------------------------------
-- Frame scripts (called from TableInspector XML in DebugTools.xml)
------------------------------------------------------------------------------
function TableInspectorFrame_OnLoad(self)
self.rows = {};
_TableInspectorFrame = self;
self:EnableMouse(true);
self:EnableMouseWheel(true);
self:SetScript("OnMouseWheel", function()
TableInspectorFrame_OnMouseWheel(this, arg1);
end);
for i = 1, TABLE_INSPECTOR_NUM_ROWS do
local row = CreateFrame("Button", "TableInspectorFrameRow" .. i, self,
"TableInspectorRowTemplate");
row:SetPoint("TOPLEFT", 12, -(56 + (i - 1) * TABLE_INSPECTOR_ROW_HEIGHT));
row:SetPoint("RIGHT", -28, 0);
row.rowIndex = i;
table.insert(self.rows, row);
end
end
function TableInspectorFrame_Update()
if (not _TableInspectorFrame or not _TableInspectorFrame.rows) then return; end
local scroll = getglobal("TableInspectorFrameScroll");
if (not scroll) then return; end
local total = table.getn(_entries);
local visible = TABLE_INSPECTOR_NUM_ROWS;
local maxOffset = math.max(0, total - visible);
scroll:SetMinMaxValues(0, maxOffset);
local offset = math.floor(scroll:GetValue() + 0.5);
if (offset > maxOffset) then offset = maxOffset; end
if (offset < 0) then offset = 0; end
-- Update breadcrumb each tick — cheap (string concat over a tiny
-- nav stack) and avoids a separate "breadcrumb changed" signal.
local crumb = getglobal("TableInspectorFrameBreadcrumb");
if (crumb) then
crumb:SetText(_Breadcrumb() .. string.format(" (%d entries)", total));
end
for i = 1, visible do
local row = _TableInspectorFrame.rows[i];
local entry = _entries[offset + i];
if (entry) then
local keyFS = getglobal(row:GetName() .. "Key");
local valFS = getglobal(row:GetName() .. "Value");
keyFS:SetText(_FormatKey(entry.key));
local disp, vt, drillable = _FormatValue(entry.value);
valFS:SetText(disp);
valFS:SetTextColor(_ValueColor(vt));
row.entryKey = entry.key;
row.entryValue = entry.value;
row.drillable = drillable;
row:Show();
else
row.entryKey = nil;
row.entryValue = nil;
row.drillable = false;
row:Hide();
end
end
end
function TableInspectorFrame_OnMouseWheel(self, delta)
local scroll = getglobal("TableInspectorFrameScroll");
if (not scroll) then return; end
local mn, mx = scroll:GetMinMaxValues();
local v = scroll:GetValue() - delta * 3;
if (v < mn) then v = mn; end
if (v > mx) then v = mx; end
scroll:SetValue(v);
end
function TableInspectorRow_OnClick(self)
if (not self.drillable or type(self.entryValue) ~= "table") then return; end
TableInspector_Push(self.entryValue, _FormatKey(self.entryKey));
end
------------------------------------------------------------------------------
-- Slash command — /tinspect <luaexpr>
--
-- Evaluates `<luaexpr>` in a fresh chunk with `return ` prefixed, so
-- bare expressions (`_G`, `GameTooltip`, `MyAddOn.foo`) work without
-- the user having to write `return ...` themselves.
------------------------------------------------------------------------------
local function _ChatErr(msg)
DEFAULT_CHAT_FRAME:AddMessage("|cffff8888TableInspector:|r " .. msg);
end
function TableInspector_HandleSlashCmd(msg)
if (not msg) then msg = ""; end
msg = string.gsub(msg, "^%s*(.-)%s*$", "%1"); -- trim
if (msg == "" or msg == "help") then
DEFAULT_CHAT_FRAME:AddMessage(
"|cff88ff88/tinspect|r <expr> -- open inspector on the result of <expr>");
DEFAULT_CHAT_FRAME:AddMessage(" example: /tinspect _G");
DEFAULT_CHAT_FRAME:AddMessage(" example: /tinspect ChatTypeInfo");
return;
end
if (msg == "close") then
TableInspector_Close();
return;
end
local chunk, parseErr = loadstring("return " .. msg);
if (not chunk) then
_ChatErr("parse error: " .. tostring(parseErr));
return;
end
-- Run in the global environment so the user's expression sees
-- the usual addon globals. setfenv on the loadstring chunk
-- defaults to that already in vanilla, but be explicit.
setfenv(chunk, getfenv(0));
local ok, result = pcall(chunk);
if (not ok) then
_ChatErr("eval error: " .. tostring(result));
return;
end
if (type(result) ~= "table") then
_ChatErr(string.format(
"result is %s (%s), expected table",
type(result), tostring(result)));
return;
end
TableInspector_Show(result, msg);
end
SLASH_TINSPECT1 = "/tinspect";
SLASH_TINSPECT2 = "/tins";
SlashCmdList["TINSPECT"] = TableInspector_HandleSlashCmd;
+20 -20
View File
@@ -76,24 +76,18 @@ endif()
add_library(${PROJECT_NAME} SHARED
${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES})
# Embed `AddOns/!!!ClassicAPI/` and `src/bindings/Bindings.xml` as RCDATA
# Embed the addons under `src/embed/AddOns/` and `src/bindings/Bindings.xml` as RCDATA
# resources. The file-read hook in `src/addons/Embedded.cpp` serves the
# addon files when the engine asks for paths under
# `Interface\AddOns\!!!ClassicAPI\`, so the addon loads even when the user
# doesn't have it on disk. `src/bindings/Inject.cpp` splices the bindings
# `Interface\AddOns\<addon>\`, so the addons load even when the user
# doesn't have them on disk. `src/bindings/Inject.cpp` splices the bindings
# XML (our TARGETING-group additions) into the engine's
# `Interface\FrameXML\Bindings.xml` at load time.
include(cmake/embed_resources.cmake)
set(EMBEDDED_CLASSICAPI_SRC "${CMAKE_CURRENT_SOURCE_DIR}/AddOns/!!!ClassicAPI")
file(GLOB_RECURSE EMBEDDED_CLASSICAPI_FILES CONFIGURE_DEPENDS RELATIVE
"${EMBEDDED_CLASSICAPI_SRC}" "${EMBEDDED_CLASSICAPI_SRC}/*")
# Never embed the developer marker: it must reflect on-disk presence only
# (see DiskHasDevMarker in src/addons/Embedded.cpp).
list(FILTER EMBEDDED_CLASSICAPI_FILES EXCLUDE REGEX "(^|/)\\.classicapi-dev$")
embed_resources(
TARGET ${PROJECT_NAME}
ADDON_DIR "${EMBEDDED_CLASSICAPI_SRC}"
ADDON_FILES ${EMBEDDED_CLASSICAPI_FILES}
ADDONS_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/src/embed/AddOns"
ADDONS "!!!ClassicAPI" "ClassicAPI_DebugTools"
ADDON_VERSION "${CLASSICAPI_TOC_VERSION}"
BINDINGS_XML "${CMAKE_CURRENT_SOURCE_DIR}/src/bindings/Bindings.xml"
OUT_DIR "${CMAKE_BINARY_DIR}/generated")
@@ -104,8 +98,7 @@ target_compile_definitions(
# Stamp the PE VERSIONINFO resource from the same version components, so the
# DLL's file/product version (Explorer -> Properties -> Details) matches the
# CLASSIC_API_VERSION Lua global. The .rsrc section the resource lands in is
# untouched by the .rdata/.pdata section merges below.
# CLASSIC_API_VERSION Lua global.
set(CLASSICAPI_VER_STRING
"${CLASSICAPI_VER_MAJOR}.${CLASSICAPI_VER_MINOR}.${CLASSICAPI_VER_PATCH}.0")
set(CLASSICAPI_VERSION_RC "${CMAKE_BINARY_DIR}/generated/version.rc")
@@ -133,16 +126,19 @@ target_include_directories(
${CMAKE_BINARY_DIR}/generated
external/minhook/include
external
external/tinycbor/src)
external/tinycbor/src
external/unordered_dense/include)
set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME "ClassicAPI")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /MT")
# /Zi retains full debug info (source file + line numbers, locals, types) in a
# separate PDB WITHOUT disabling optimization — the DLL stays a fully optimized
# /O2 release build; the symbols just live in ClassicAPI.pdb on the side. Feeds
# the linker's /DEBUG below.
set(CMAKE_CXX_FLAGS_RELEASE "/MT /O2 /Ob2 /DNDEBUG /Zi")
# /O1 optimizes for size: the code is mostly short glue around engine calls,
# and /O1 made the DLL 280 KB smaller than /O2 /Ob2 (link-time code generation,
# /GL + /LTCG, made it larger again). /Zi retains full debug info (source file +
# line numbers, locals, types) in a separate PDB WITHOUT disabling
# optimization; the symbols just live in ClassicAPI.pdb on the side. Feeds the
# linker's /DEBUG below.
set(CMAKE_CXX_FLAGS_RELEASE "/MT /O1 /DNDEBUG /Zi")
set(CMAKE_SHARED_LINKER_FLAGS
# Two debug artifacts for triaging crashes from a SHIPPED DLL. Both are
# published as release artifacts, match this exact build, and have no effect
@@ -159,5 +155,9 @@ set(CMAKE_SHARED_LINKER_FLAGS
# identical-COMDAT fold); they are listed explicitly below so they stay ON
# and the shipped DLL does not grow. (/OPT:ICF folds identical functions, so
# a few map symbols share an address — harmless.)
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT /MAP /DEBUG"
# The sections keep the standard layout: .text is read + execute only and
# constants stay in a read-only .rdata. Nothing writes into this DLL's own
# code; engine patches make their target writable themselves
# (Common::PatchBytes).
"/OPT:REF /OPT:ICF /NODEFAULTLIB:MSVCRT /MAP /DEBUG"
)
+84 -89
View File
@@ -15,36 +15,24 @@ The headline features. Each one takes effect as soon as the DLL loads, so your
existing addons benefit without any code changes. The complete per-function
reference is in **[docs/API.md](docs/API.md)**.
### Lua 5.1 compatibility
The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
| Feature | Effect |
|---------|--------|
| [Syntax](docs/API.md#lua-51-syntax) | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). |
| [Upvalue limit](docs/API.md#upvalue-limit) | A function can use up to 60 upvalues, the outer locals it refers to, matching Lua 5.1. Lua 5.0 stops at 32 and rejects the whole file, so a large handler that reads many file-level locals can be valid 5.1 and still fail to load here. Nothing changes for a function within 32. |
| [`for` loop variables](docs/API.md#for-loop-variables-per-pass) | Each pass of a `for` loop has its own loop variables, as in Lua 5.1, so a function made in the loop keeps that pass's values. On Lua 5.0 every such function sees the loop's last value, which is `nil` for a `for ... in` loop. |
| [String methods](docs/API.md#string-methods-supper-sformat) | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. Works on literals and variables, in-world and on the login screen, and inside coroutines. |
| [Script-handler arguments](docs/API.md#setmodernscriptargsenable--getmodernscriptargs) | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. |
| [XML script bodies](docs/API.md#xml-script-bodies-see-self-and-their-arguments) | An XML script body written for later clients, such as `<OnLoad>self:Init()</OnLoad>`, sees `self` and its script's arguments by name (`elapsed`, `button`, ...). Bodies that read `this` / `arg1` compile as before. |
### Modern client behaviors
| Feature | Effect |
|---------|--------|
| Lua 5.1 code | Addon code written for Lua 5.1 runs on the 1.12 Lua 5.0 VM. It covers the [5.1 syntax](docs/API.md#lua-51-syntax) (`#`, `%`, `...` expressions, `0x` hex, `[=[ ]=]` long brackets), [method calls on strings](docs/API.md#string-methods-supper-sformat) (`msg:match("^!(%w+)")`), [up to 60 upvalues](docs/API.md#upvalue-limit) for each function, and [new `for` loop variables](docs/API.md#for-loop-variables-per-pass) on each pass. Each addon file also gets its `(name, table)` through `...`. |
| [Modern script handlers](docs/API.md#setmodernscriptargsenable--getmodernscriptargs) | Frame scripts get their values as arguments, for example `OnClick(self, button, down)` and `OnEvent(self, event, ...)`. [XML script bodies](docs/API.md#xml-script-bodies-see-self-and-their-arguments) such as `<OnLoad>self:Init()</OnLoad>` see `self` and their arguments by name. The `this` / `arg1` globals stay set. `SetModernScriptArgs(false)` restores the vanilla behavior. |
| Inline textures | Draws inline texture markup (`\|T…\|t`) and atlas markup (`\|A…\|a`) as icons in FontStrings, chat, and tooltips. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
| [Soft interact](docs/API.md#soft-interact-softinteract--anyinteract) | The Interact Key. After you bind **Interact With Target** to a key, the key interacts with the nearest object in front of you. This object can be an NPC with a service, a corpse that you can loot or skin, a mailbox, a chest, or an herb or mining node. The `softinteract` and `anyinteract` unit tokens point at the object, and `PLAYER_SOFT_INTERACT_CHANGED` fires when it changes. When `SoftTargetIconInteract` is `1`, an icon over the object shows the cursor that the mouse shows over it. |
| [Soft enemy and soft friend](docs/API.md#soft-enemy-and-soft-friend-softenemy--softfriend--anyenemy--anyfriend) | The `softenemy` and `softfriend` unit tokens point at the nearest enemy and the nearest friendly unit, and your target does not change. When your target qualifies, they point at your target. `anyenemy` and `anyfriend` use your target first. `PLAYER_SOFT_ENEMY_CHANGED` and `PLAYER_SOFT_FRIEND_CHANGED` fire when the units change. With no target, an attack goes to `softenemy`, a harmful spell goes to `softenemy` and a helpful spell goes to `softfriend`. A harmful spell makes its unit a loose target, which follows the nearest soft enemy. A dimmer selection circle shows under each unit. `SoftTargetIconEnemy` and `SoftTargetIconFriend` show an icon over each unit. Turn them on with `SoftTargetEnemy` and `SoftTargetFriend`. |
| [Soft targeting](docs/API.md#soft-interact-softinteract--anyinteract) | The Interact Key and soft targets. Bind **Interact With Target** to a key to interact with the nearest NPC, corpse, mailbox, chest or gathering node in front of you. The [`softenemy` and `softfriend`](docs/API.md#soft-enemy-and-soft-friend-softenemy--softfriend--anyenemy--anyfriend) tokens point at the nearest enemy and friendly unit without a change of target. With no target, harmful spells go to `softenemy` and helpful spells go to `softfriend`. `PLAYER_SOFT_*_CHANGED` events fire when the units change. |
| [Animations](docs/API.md#animation) | Animation groups on frames, textures and font strings, made in Lua or XML: `CreateAnimationGroup`, looping, smoothing, scripts and templates, with `Alpha`, `Translation`, `Scale`, `Rotation` and `Path` applied. |
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
| [XML attributes](docs/API.md#parentkey-in-xml) | More XML attributes work: `parentKey="Name"` makes `parent.Name` point to a frame, texture or font string, `<OnLoad function="Name"/>` uses a global function as a script handler, `<OnLoad method="Name"/>` uses the frame's own function, `mixin="A, B"` copies mixins onto the object, `<KeyValues>` sets fields on the object, a button's `<NormalFont style="..."/>` sets its font, and `inherits="A, B"` applies more than one template. |
| Tooltip line cap | Removes `GameTooltip`'s 30-line limit for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). A tooltip adds a new line pair when it needs one, so long tooltips and comparison blocks (for example, pfUI's eqcompare) keep all their lines. |
| [XML attributes](docs/API.md#parentkey-in-xml) | More XML attributes work: `parentKey="Name"` makes `parent.Name` point to a frame, texture or font string, `<OnLoad function="Name"/>` uses a global function as a script handler, `<OnLoad method="Name"/>` uses the frame's own function, `mixin="A, B"` copies mixins onto the object, `<KeyValues>` sets fields on the object, a button's `<NormalFont style="..."/>` sets its font, and `inherits="A, B"` applies more than one template. `parentArray="Name"` adds the object to a list on its parent. An `<Anchor>` takes `x` and `y` offsets and `relativeKey="$parent.Key"`. `<OnEnter inherit="prepend">` runs the template's handler and then its own, and `inherit="append"` runs them in the other order. |
| [Macro conditions](docs/API.md#more-commands-with-conditions) | `[conditions]` and `@unit` work on 38 slash commands, from `/cast` and `/use` to `/target`, `/follow`, `/cancelaura` and the `/pet` family. With `/cast [@mouseover,harm][] Fireball`, the spell goes to the mouseover unit. With no mouseover unit, it goes to your target. `@cursor` places a ground-target spell or item under the mouse and `@player` places it at your feet. A character name works where a unit token does (`[target=Feral]`). Addons read the same rules through `SecureCmdOptionParse`. |
| [`#showtooltip`](docs/API.md#showtooltip-and-show) | A macro button shows what the macro does. The icon, tooltip, cooldown, range, usable state and auto-repeat glow all follow the spell or item that the directive resolves to, conditions included. They update as the conditions change. The result goes into the engine field that action-bar addons already read, so they need no code of their own. An addon with its own macro parser can publish its result through `C_Macro.SetMacroDisplay`. It then does not need to replace the action globals. |
| [`#showtooltip`](docs/API.md#showtooltip-and-show) | A macro button shows the spell or item that its directive resolves to, conditions included: icon, tooltip, cooldown, range, usable state and auto-repeat glow. Action-bar addons get this with no code of their own. Addons with their own macro parser can publish through `C_Macro.SetMacroDisplay`. |
| [Event-driven nameplates](docs/API.md#nameplate) | The modern `C_NamePlate` API, driven by real events. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. |
| [Focus target](docs/API.md#focus) | A sticky focus unit. `FocusUnit("target")` pins it and `ClearFocus()` drops it, with `PLAYER_FOCUS_CHANGED` on every change. The `focus` / `focustarget` tokens resolve with every `UnitX` function and fire unit events (`UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "focus"`), and the predefined `FOCUSTARGET` / `TARGETFOCUS` keybinds are ready to set. |
| [Combat log events](docs/API.md#combat_log_event_unfiltered--combat_log_event-events) | `COMBAT_LOG_EVENT_UNFILTERED` and `CombatLogGetCurrentEventInfo` give each combat event with GUIDs and `COMBATLOG_OBJECT_*` flags. The values do not depend on the client language, so damage meters do not need to parse chat text. `COMBAT_LOG_EVENT` fires for the events that pass the `CombatLogAddFilter` filters. |
| [Retail-like `/reload`](docs/API.md#reload-picks-up-new-addons-and-new-files) | `/reload` picks up addon changes made while the game runs. A new folder under `Interface\AddOns\` registers and loads as a normal addon, new files added to an existing addon's TOC load, a newly installed addon's first SavedVariables save survives `/reload`, `##` metadata edits (`## SavedVariables:`, `## Dependencies:`, `## Title:`, …) take effect, and a deleted addon folder drops from the addon list. |
| [Multi-flavor & conditional TOC](docs/API.md#conditional-and-multi-flavor-toc-loading) | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. |
| [Launch switches](docs/API.md#launch) | Options you add to the shortcut that starts the game. `-config <name>` reads and writes `WTF\<name>` in place of `WTF\Config.wtf`, so one install can hold several settings profiles. `-gluescript` and `-gluescriptFile` run Lua at the login and character-select screens, every time those screens appear. `-gamescript` and `-gamescriptFile` run it one time, after you enter the world. Each script runs after the interface has loaded, so it can call interface functions and use frames. |
| [Addon memory and CPU usage](docs/API.md#profiling) | Addon managers and performance tools can show how much Lua memory and CPU time each addon uses. `GetAddOnMemoryUsage` gives the memory of each addon. When `scriptProfile` is `1`, the CPU functions give the time of each addon, function, frame, and event. With profiling off, the CPU functions cost nothing. |
| [Any texture size](docs/API.md#texture-size-and-shape) | Textures load at any size and any shape. Width and height do not need to be powers of two, and there is no fixed upper limit. The only limit is the maximum texture size of your graphics card. This applies to every texture path: `SetTexture`, sprite sheets, inline textures, tooltips, and masks. The client allocates memory for a large texture only when an addon loads one, so small textures cost nothing extra. |
Smaller behavior fixes, such as frame alpha and `SetParent` frame levels, are
@@ -63,86 +51,91 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [Action](docs/API.md#action) | `GetActionInfo`, `IsItemAction`, `HasBonusActionBar` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnInterfaceVersion`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded`, `C_AddOns.LoadAddOn` |
| [APIDocumentation](docs/API.md#apidocumentation) | `/classicapi` in-game browser, `C_APIDocumentation.GetSystem`, `C_APIDocumentation.GetSystems` |
| [Animation](docs/API.md#animation) | `region:CreateAnimationGroup`, `region:GetAnimationGroups`, `region:StopAnimating`, `SetMaxAnimFramerate`, `GetMaxAnimFramerate`, `AnimationGroup` and `Animation` / `Alpha` / `Translation` / `Scale` / `Rotation` / `Path` / `ControlPoint` methods |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem`, `CanCancelAuction` |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings`, `GetBindingAction` (`checkOverride`), `GetBindingByKey`, `IsModifiedClick`, `GetModifiedClick`, `SetModifiedClick`, `GetNumModifiedClickActions`, `GetModifiedClickAction` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [Chat](docs/API.md#chat) | `GetCurrentChatEvent`, `GetCurrentChatGUID`, `IsChatAFK`, `IsChatDND`, `SendSystemMessage` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [ClassColor](docs/API.md#classcolor) | `C_ClassColor.GetClassColor` |
| [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` |
| [Combat](docs/API.md#combat) | `InCombatLockdown`, `StartAttack`, `StopAttack` |
| [CombatLog](docs/API.md#combatlog) | `CombatLogGetCurrentEventInfo`, `COMBATLOG_OBJECT_*` (constants) |
| [Console](docs/API.md#console) | `CalculateStringEditDistance`, `ConsoleEcho`, `ConsoleExec`, `ConsoleGetAllCommands`, `ConsoleGetColorFromType`, `ConsoleGetFontHeight`, `ConsoleIsActive`, `ConsolePrintAllMatchingCommands`, `SetConsoleKey` |
| [Console](docs/API.md#console) | `CalculateStringEditDistance`, `ConsoleAddMessage`, `ConsoleEcho`, `ConsoleExec`, `ConsoleGetAllCommands`, `ConsoleGetColorFromType`, `ConsoleGetFontHeight`, `ConsoleIsActive`, `ConsolePrintAllMatchingCommands`, `SetConsoleKey` |
| [CVar](docs/API.md#cvar) | `C_CVar.AreCVarsLoaded`, `C_CVar.DoesCVarExist`, `C_CVar.GetCVarBitfield`, `C_CVar.GetCVarBool`, `C_CVar.GetCVarInfo`, `C_CVar.RemoveTempCVar`, `C_CVar.SetCVar`, `C_CVar.SetCVarBitfield`, `C_CVar.SetTempCVar`, `GetCVar`, `GetCVarDefault` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.AutoStoreItem`, `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemEquipmentSetInfo`, `C_Container.GetContainerItemQuestInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerFreeSlots`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetBackpackAutosortDisabled`, `C_Container.GetBankAutosortDisabled`, `C_Container.GetItemCooldown`, `C_Container.GetSortBagsRightToLeft`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SetBackpackAutosortDisabled`, `C_Container.SetBankAutosortDisabled`, `C_Container.SetSortBagsRightToLeft`, `C_Container.SortBags`, `C_Container.SortBankBags`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Cursor](docs/API.md#cursor) | `CursorHasMacro`, `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.AutoStoreItem`, `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemEquipmentSetInfo`, `C_Container.GetContainerItemQuestInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerFreeSlots`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetBackpackAutosortDisabled`, `C_Container.GetBankAutosortDisabled`, `C_Container.GetItemCooldown`, `C_Container.GetSortBagsRightToLeft`, `C_Container.HasContainerItem`, `C_Container.IsContainerFiltered`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SetBackpackAutosortDisabled`, `C_Container.SetBagPortraitTexture`, `C_Container.SetBankAutosortDisabled`, `C_Container.SetItemSearch`, `C_Container.SetSortBagsRightToLeft`, `C_Container.SortBags`, `C_Container.SortBankBags`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown`, `SetBagPortraitTexture` |
| [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` |
| [Currency](docs/API.md#currency) | `GetCoinTextureString`, `C_CurrencyInfo.GetCoinTextureString` |
| [Currency](docs/API.md#currency) | `GetCoinText`, `C_CurrencyInfo.GetCoinText`, `GetCoinTextureString`, `C_CurrencyInfo.GetCoinTextureString` |
| 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` |
| [DeathInfo](docs/API.md#deathinfo) | `C_DeathInfo.GetSelfResurrectOptions`, `C_DeathInfo.UseSelfResurrectOption` |
| [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.PickupEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByID`, `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.IsFactionActive`, `C_Reputation.IsFactionActiveByID`, `C_Reputation.IsFactionInactive`, `C_Reputation.SetFactionActive`, `C_Reputation.SetFactionActiveByID`, `C_Reputation.SetFactionInactive`, `C_Reputation.SetFactionInactiveByID`, `C_Reputation.SetSelectedFaction`, `C_Reputation.SetSelectedFactionByID`, `C_Reputation.SetWatchedFactionByID`, `C_Reputation.ToggleFactionAtWar`, `C_Reputation.ToggleFactionAtWarByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetAccountExpansionLevel`, `GetClassicExpansionLevel`, `GetExpansionLevel`, `GetMaxPlayerLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByID`, `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.IsFactionActive`, `C_Reputation.IsFactionActiveByID`, `C_Reputation.IsFactionInactive`, `C_Reputation.SetFactionActive`, `C_Reputation.SetFactionActiveByID`, `C_Reputation.SetFactionInactive`, `C_Reputation.SetFactionInactiveByID`, `C_Reputation.SetSelectedFaction`, `C_Reputation.SetSelectedFactionByID`, `C_Reputation.SetWatchedFactionByID`, `C_Reputation.ToggleFactionAtWar`, `C_Reputation.ToggleFactionAtWarByID`, `CollapseAllFactionHeaders`, `ExpandAllFactionHeaders`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame: Region](docs/API.md#regionsetpointpoint-one-argument-form) | `:SetPoint("point")` (one-arg form), `:SetSize` / `:GetSize`, `:GetRect`, `:IsMouseOver`, `:IsDragging`, `:IsProtected` / `:CanChangeProtectedState` |
| [Frame: Frame](docs/API.md#createframeframetype--name-parent-template-id) | `CreateFrame` template lists and `id`, `GetMouseFoci`, `:SetShown`, `:SetClampRectInsets` / `:GetClampRectInsets`, `:SetResizeBounds`, `:GetBoundsRect`, `:GetEffectiveAlpha`, `:HookScript`, `:IsEventRegistered`, `:RegisterUnitEvent`, `:SetPropagateKeyboardInput` / `:GetPropagateKeyboardInput`, `:SetDontSavePosition` / `:GetDontSavePosition`, `:SetAttribute`, `:SetAttributeNoHandler`, `:ClearAttribute`, `:GetAttribute`, `:CanChangeAttribute`, `:AllowAttributeChanges` |
| [Frame: Region](docs/API.md#regionsetpointpoint-one-argument-form) | `:SetPoint("point")` (one-arg form), `:SetSize` / `:GetSize`, `ignoreRect` on `:GetSize` / `:GetWidth` / `:GetHeight`, `:GetRect`, `:GetScaledRect`, `:IsRectValid`, `:GetPointByName`, `:ClearPoint`, `:GetScale` / `:GetEffectiveScale` (on textures and font strings), `:GetDebugName`, `:IsMouseOver`, `:IsDragging`, `:IsProtected` / `:CanChangeProtectedState` |
| [Frame: Frame](docs/API.md#createframeframetype--name-parent-template-id) | `CreateFrame` template lists and `id`, `GetMouseFoci`, `:SetShown`, `:SetClampRectInsets` / `:GetClampRectInsets`, `:SetResizeBounds`, `:SetClipsChildren` / `:DoesClipChildren`, `:GetBoundsRect`, `:GetEffectiveAlpha`, `:SetAlphaGradient` / `:ClearAlphaGradient` / `:HasAlphaGradient`, `:DesaturateHierarchy`, `:HookScript`, `:IsEventRegistered`, `:RegisterUnitEvent`, `:SetPropagateKeyboardInput` / `:GetPropagateKeyboardInput`, `:SetDontSavePosition` / `:GetDontSavePosition`, `:SetAttribute`, `:SetAttributeNoHandler`, `:ClearAttribute`, `:GetAttribute`, `:CanChangeAttribute`, `:AllowAttributeChanges` |
| [Frame: Button](docs/API.md#buttonsetnormalfontobjectfont--buttongetnormalfontobject) | `:SetEnabled`, `:SetFormattedText`, `:SetNormalFontObject` / `:GetNormalFontObject`, `:SetMotionScriptsWhileDisabled` / `:GetMotionScriptsWhileDisabled`, `:RegisterForClicks("AnyUp"/"AnyDown")`, `:RegisterForMouse`, `GetClickFrame` |
| [Frame: FontString](docs/API.md#fontstringgetstringheight) | `:GetStringHeight`, `:GetUnboundedStringWidth`, `:GetWrappedWidth`, `:GetNumLines`, `:GetLineHeight`, `:GetFontHeight` / `:SetFontHeight` (also on fonts), `:GetFieldSize`, `:IsTruncated`, `:SetMaxLines` / `:GetMaxLines`, `:SetWordWrap` / `:CanWordWrap`, `:SetIndentedWordWrap` / `:GetIndentedWordWrap` (also on fonts, edit boxes, message frames, and SimpleHTML), `:SetFormattedText`, `:SetRotation` / `:GetRotation` |
| [Frame: Texture](docs/API.md#texturesetrotationangle--cx-cy) | `:SetRotation` / `:GetRotation`, `:SetVertexOffset` / `:GetVertexOffset`, `:SetColorTexture`, `:SetAtlas` / `:GetAtlas`, `:ResetTexCoord`, `:SetSpriteSheetCell`, `:SetDesaturation` / `:GetDesaturation`, `IsDesaturateSupported`, `:SetMask`, `frame:CreateMaskTexture`, `:AddMaskTexture` / `:RemoveMaskTexture`, `:GetNumMaskTextures`, `:GetMaskTexture` |
| [Frame: EditBox](docs/API.md#editboxsetcursorpositionposition) | `:SetCursorPosition` / `:GetCursorPosition`, `:GetUTF8CursorPosition`, `:ClearHighlightText`, `:SetHighlightColor` / `:GetHighlightColor`, `:HasFocus`, `GetCurrentKeyBoardFocus`, `:HasText`, `:ClearHistory`, `:Enable` / `:Disable` / `:IsEnabled`, `:SetCountInvisibleLetters` / `:IsCountInvisibleLetters`, `:IsInIMECompositionMode` |
| [Frame: StatusBar](docs/API.md#statusbarsetreversefillisreversefill--statusbargetreversefill) | `:SetReverseFill` / `:GetReverseFill`, `:SetRotatesTexture` / `:GetRotatesTexture`, `:SetFillStyle` / `:GetFillStyle`, `:SetColorFill`, `:SetStatusBarDesaturated` / `:IsStatusBarDesaturated`, `:SetStatusBarDesaturation` / `:GetStatusBarDesaturation` |
| [Frame: Slider](docs/API.md#sliderenable--sliderdisable--sliderisenabled) | `:Enable` / `:Disable` / `:IsEnabled` |
| [Frame: Cooldown](docs/API.md#createframecooldown--name-parent-template) | `CreateFrame("Cooldown")`, `:SetCooldown`, `:SetReverse` / `:GetReverse`, `:SetDrawEdge` / `:GetDrawEdge` |
| [Frame: ScrollingMessageFrame](docs/API.md#smfsethyperlinksenabledenabled--smfgethyperlinksenabled) | `:SetHyperlinksEnabled` / `:GetHyperlinksEnabled` |
| [Frame: Cooldown](docs/API.md#createframecooldown--name-parent-template) | `CreateFrame("Cooldown")`, `:SetCooldown`, `:SetCooldownDuration`, `:SetCooldownFromExpirationTime`, `:Clear`, `:GetCooldownTimes`, `:GetCooldownDuration`, `:GetCooldownDisplayDuration`, `:Pause` / `:Resume`, `:SetPaused` / `:IsPaused`, `:SetReverse` / `:GetReverse`, `:SetDrawEdge` / `:GetDrawEdge`, `:SetDrawSwipe` / `:GetDrawSwipe`, `:SetDrawBling` / `:GetDrawBling`, `:SetSwipeColor`, `:SetEdgeColor`, `:SetSwipeTexture`, `:SetEdgeTexture`, `:SetBlingTexture`, `:SetRotation` / `:GetRotation`, `:SetHideCountdownNumbers` / `:GetHideCountdownNumbers`, `:GetCountdownFontString`, `:SetCountdownAbbrevThreshold` / `:GetCountdownAbbrevThreshold`, `:SetCountdownMillisecondsThreshold` / `:GetCountdownMillisecondsThreshold`, `:SetMinimumCountdownDuration` / `:GetMinimumCountdownDuration`, `OnCooldownDone`, `CooldownFrame_Set`, `CooldownFrame_Clear`, `CooldownFrame_SetDisplayAsPercentage` |
| [Frame: ScrollingMessageFrame](docs/API.md#smfsethyperlinksenabledenabled--smfgethyperlinksenabled) | `:SetHyperlinksEnabled` / `:GetHyperlinksEnabled`, `:GetMessageInfo` (with the extra `:AddMessage` values), `:BackFillMessage` (adds a message as the oldest), `:RemoveMessagesByPredicate`, `:SetScrollOffset` / `:GetScrollOffset`, `:GetMaxScrollRange`, `:ScrollByAmount`, `:SetInsertMode` / `:GetInsertMode`, `:AddOnDisplayRefreshedCallback` |
| [Frame: scripts](docs/API.md#onenable-and-ondisable-scripts) | `OnEnable`, `OnDisable`, `OnMinMaxChanged`, `OnAttributeChanged`, `OnCharComposition`, `OnTextChanged` (`userInput`), `PreClick`, `PostClick`, `SetModernScriptArgs` / `GetModernScriptArgs` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
| [FriendList](docs/API.md#friendlist) | `AddOrRemoveFriend`, `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `GameTooltip:AddTexture`, `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetMinimumWidth`, `GameTooltip:GetOwner`, `GameTooltip:GetPadding`, `GameTooltip:GetSpell`, `GameTooltip:GetUnit`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:IsUnit`, `GameTooltip:SetAnchorType`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetFrameStack`, `GameTooltip:SetHyperlink` (full links, `spell:`, `quest:`), `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem`, `OnTooltipSetSpell`, `OnTooltipSetUnit`, `OnTooltipSetGameObject`, `OnTooltipSetFrameStack` (scripts) |
| [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`, `GetNumGossipActiveQuests`, `GetNumGossipAvailableQuests` |
| [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.GetPoiForUiMapID`, `C_GossipInfo.GetPoiInfo`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex`, `GetNumGossipActiveQuests`, `GetNumGossipAvailableQuests`, `GetNumGossipOptions` |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `GetMouseButtonName`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo`, `IsActiveBattlefieldArena` |
| [Item: C_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.GetItemClassInfo`, `C_Item.GetItemCount`, `C_Item.GetItemData` / `C_Item.GetItemDataByID`, `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.GetItemTempEnchantInfo`, `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.IsUsableItem`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockItem`, `C_Item.UnlockAllItems`, `C_Item.PickupItem`, `C_Item.RequestLoadItemData` / `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName` |
| [Item: info](docs/API.md#getitemclassinfoclassid) | `GetItemClassInfo`, `GetItemSubClassInfo`, `GetItemIcon`, `IsConsumableItem`, `IsUsableItem` |
| [Item: inventory](docs/API.md#getaverageitemlevel) | `GetAverageItemLevel`, `GetInventoryItemID`, `GetInventoryItemDurability`, `GetInventoryItemRepairCost`, `GetInventoryItemsForSlot`, `OffhandHasWeapon` |
| [Item: C_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.GetItemClassInfo`, `C_Item.GetItemCount`, `C_Item.GetItemData` / `C_Item.GetItemDataByID`, `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.GetItemTempEnchantInfo`, `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.IsUsableItem`, `C_Item.ItemHasRange`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockItem`, `C_Item.UnlockAllItems`, `C_Item.PickupItem`, `C_Item.RequestLoadItemData` / `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName` |
| [Item: info](docs/API.md#getitemclassinfoclassid) | `GetItemClassInfo`, `GetItemSubClassInfo`, `GetItemIcon`, `IsConsumableItem`, `IsCurrentItem`, `IsDressableItem`, `IsEquippedItemType`, `IsHarmfulItem`, `IsHelpfulItem`, `IsUsableItem` |
| [Item: inventory](docs/API.md#getaverageitemlevel) | `GetAverageItemLevel`, `GetInventoryItemID`, `GetInventoryItemDurability`, `GetInventoryItemRepairCost`, `GetInventoryItemsForSlot`, `IsDualWielding`, `SetInventoryPortraitTexture` |
| [Item: item IDs](docs/API.md#getitemid--companions-to-the-engines-getitemlink-family) | `GetAuctionItemID`, `GetAuctionSellItemID`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeTargetItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootAllCorpses`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount`, `C_LossOfControl.GetSchoolLockout` |
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.gsub` (table replacement), `string.join`, `string.match`, `string.reverse`, `string.split`, `string.trim`, `strjoin`, `strmatch`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args), `xpcall` (argument forwarding) |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `C_Macro.SetMacroDisplay`, `DeleteMacro` / `EditMacro` / `GetMacroInfo` / `PickupMacro` by name, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItem`, `GetMacroItemIcons`, `GetMacroSpell`, `StopMacro` |
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `strcmputf8i`, `strconcat`, `string.concat`, `string.gmatch`, `string.gsub` (table replacement), `string.join`, `string.match`, `string.reverse`, `string.split`, `string.trim`, `strjoin`, `strlenutf8`, `strmatch`, `strreplace`, `strrev`, `strsplit`, `strsplittable`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args), `xpcall` (argument forwarding) |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `C_Macro.SetMacroDisplay`, `DeleteMacro` / `EditMacro` / `GetMacroInfo` / `PickupMacro` by name, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroBody`, `GetMacroIcons`, `GetMacroItem`, `GetMacroItemIcons`, `GetMacroSpell`, `GetRunningMacro`, `GetRunningMacroButton`, `RunMacroText`, `SetMacroItem`, `SetMacroSpell`, `StopMacro` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.CanSetUserWaypointOnMap`, `C_Map.ClearUserWaypoint`, `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetFallbackWorldMapID`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapArtLayers`, `C_Map.GetMapArtLayerTextures`, `C_Map.GetMapChildrenInfo`, `C_Map.GetMapInfo`, `C_Map.GetMapInfoAtPosition`, `C_Map.GetMapOverlays`, `C_Map.GetMapPosFromWorldPos`, `C_Map.GetMapRectOnMap`, `C_Map.GetMapWorldSize`, `C_Map.GetPlayerMapPosition`, `C_Map.GetUserWaypoint`, `C_Map.GetUserWaypointFromHyperlink`, `C_Map.GetUserWaypointHyperlink`, `C_Map.GetUserWaypointPositionForMap`, `C_Map.GetWorldPosFromMapPos`, `C_Map.HasUserWaypoint`, `C_Map.MapHasArt`, `C_Map.SetUserWaypoint` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredAreaIDsAtPosition`, `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems`, `GetBuybackItemLink` |
| [Model](docs/API.md#model) | `DressUpModel:TryOn` (links, recipes, names), `DressUpModel:UndressSlot`, `Model:SetGlow`, `PlayerModel:RefreshCamera`, `PlayerModel:SetCamDistanceScale`, `PlayerModel:SetCreature`, `PlayerModel:SetDisplayInfo`, `PlayerModel:SetPortraitZoom`, `PlayerModel:SetRotation` (`animate`) |
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
| [NameCache](docs/API.md#namecache) | `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
| [NameCache](docs/API.md#namecache) | `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
| [PartyInfo](docs/API.md#partyinfo) | `InviteUnit`, `PromoteToLeader`, `UninviteUnit` |
| [Pet](docs/API.md#pet) | `IsPetAttackAction`, `IsAtStableMaster` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetInfo`, `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestLogTitle`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Profiling](docs/API.md#profiling) | `GetAddOnCPUUsage`, `GetAddOnMemoryUsage`, `GetEventCPUUsage`, `GetFrameCPUUsage`, `GetFunctionCPUUsage`, `GetScriptCPUUsage`, `ResetCPUUsage`, `UpdateAddOnCPUUsage`, `UpdateAddOnMemoryUsage` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetInfo`, `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestLogTitle`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogCompletionText`, `GetQuestLogLeaderBoardID` (Turtle WoW only: `C_QuestLog.GetAllCompletedQuestIDs`, `C_QuestLog.IsQuestFlaggedCompleted`, `GetQuestsCompleted`, `QueryQuestsCompleted`) |
| [Sound](docs/API.md#sound) | `C_Sound.GetRecentSoundFiles`, `C_Sound.GetSoundScaledVolume`, `C_Sound.IsPlaying`, `C_Sound.PlayItemSound`, `C_Sound.PlaySound`, `C_Sound.PlaySoundWithOptions`, `C_Sound.PlayVocalErrorSound`, `MuteSoundFile`, `PlaySound`, `PlaySoundFile`, `StopSound`, `UnmuteSoundFile` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCastCount`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectInfo`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellLossOfControlCooldown`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCastCount`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectInfo`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellLossOfControlCooldown`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CancelUnitBuff`, `CastSpellByID`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetCritChance`, `GetRangedCritChance`, `GetSpellBaseCooldown`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsAttackSpell`, `IsAutoRepeatSpell`, `IsConsumableSpell`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.ContainsAnyDisenchantSpell`, `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetNumSpellBookSkillLines`, `C_SpellBook.GetPlayerSpellsByAura`, `C_SpellBook.GetSkillLineIndexByID`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemCastCount`, `C_SpellBook.GetSpellBookItemInfo`, `C_SpellBook.GetSpellBookItemLossOfControlCooldownDuration`, `C_SpellBook.GetSpellBookItemLossOfControlCooldownInfo`, `C_SpellBook.GetSpellBookItemSkillLineIndex`, `C_SpellBook.GetSpellBookSkillLineInfo`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsClassTalentSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `GetSheathState`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftForm`, `GetShapeshiftFormID`, `GetSheathState`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsLoggingOut`, `IsMounted`, `IsOutdoors`, `IsPlayerInWorld`, `IsStealthed`, `IsSwimming` |
| [SwingTimer](docs/API.md#swingtimer) | `C_SwingTimer.EnableRangeCheck`, `C_SwingTimer.IsTargetWithinSwingRange` |
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
| [System](docs/API.md#system) | `CopyToClipboard`, `FlashClientIcon`, `GetPhysicalScreenSize` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetDirectionFinished`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer`, `InteractUnit`, `C_PlayerInteractionManager.InteractUnit`, `IsTargetLoose` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
| [Texture](docs/API.md#texture) | `C_Texture.GetAtlasElementID`, `C_Texture.GetAtlasElements`, `C_Texture.GetAtlasExists`, `C_Texture.GetAtlasID`, `C_Texture.GetAtlasInfo`, `C_Texture.RegisterAtlas` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTimeCached` |
| [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`, `GetTickTime`, `GetTimeCached` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes`, `GetTradeSkillRecipeLink`, `IsTradeSkillLinked` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes`, `GetTradeSkillDescription`, `GetTradeSkillRecipeLink`, `IsTradeSkillLinked` |
| [Trainer](docs/API.md#trainer) | `GetTrainerServiceItemLink` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `IsUnitToken`, `SetUnitCursorTexture`, `UnitClassBase`, `UnitCreatedBySpell`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSelectionColor`, `UnitSelectionType`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID`, `UnitTokenFromName` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySlot`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetAuraSlots`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitAuraBySlot`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
| [Unit](docs/API.md#unit) | `CanBeRaidTarget`, `ClosestUnitPosition`, `GetUnitSpeed`, `IsUnitToken`, `SetUnitCursorTexture`, `UnitClassBase`, `UnitCreatedBySpell`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHasIncomingResurrection`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSelectionColor`, `UnitSelectionType`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID`, `UnitTokenFromName` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByAuraInstanceID`, `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySlot`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetAuraSlots`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitAuraBySlot`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
| [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` |
@@ -224,6 +217,7 @@ menu and click AddOn Options, then ClassicAPI.
| [Cast settings](docs/API.md#cast-settings-autostand-autodismount-autounshift) | `autoStand` | `0` | `1` makes you stand up when you cast while you sit. |
| | `autoDismount` | `0` | `1` makes you dismount when you cast while you are mounted. |
| | `autoUnshift` | `0` | `1` makes you leave your form when a spell cannot be cast in it. |
| [Profiling](docs/API.md#scriptprofile) | `scriptProfile` | `0` | `1` turns on CPU profiling for the `*CPUUsage` functions. The change starts after `/reload`. |
| [Soft interact](docs/API.md#soft-interact-softinteract--anyinteract) | `SoftTargetInteract` | `3` | Turns `softinteract` on: `2` keyboard and mouse, `3` always, `0` / `1` off. |
| | `SoftTargetInteractArc` | `0` | Where the object can be: `0` straight ahead of the camera, `1` within 30° of it, `2` any direction. |
| | `SoftTargetInteractRange` | `10` | Maximum distance in yards. |
@@ -239,6 +233,9 @@ menu and click AddOn Options, then ClassicAPI.
| | `SoftTargetIconEnemy` / `SoftTargetIconFriend` | `0` | `1` shows an icon over `softenemy` / `softfriend`. |
| | `SoftTargetLowPriorityIcons` | `0` | `1` also shows the normal pointer as an icon. |
| | `SoftTargetNameplateSize` | `19` | Size of all soft-target icons. |
| [Cooldown](docs/API.md#createframecooldown--name-parent-template) | `countdownForCooldowns` | `0` | `1` shows the time left as numbers on cooldown frames. |
| [NamePlate](docs/API.md#nameplate) | `nameplateNotSelectedAlpha` | `0.5` | Alpha of a non-target default nameplate, `0` to `1`. |
| | `nameplateSelectedAlpha` | `1` | Alpha of the target's default nameplate, `0` to `1`. |
| [Text to speech](docs/API.md#tts-cvars) | `ttsVoice` | `0` | The voice that `C_VoiceChat.SpeakText` uses. |
| | `ttsSpeed` | `0` | Speech rate, `-10` to `10`. |
| | `ttsVolume` | `100` | Speech volume, `0` to `100`. |
@@ -275,6 +272,7 @@ when launching with `-console`), not as Lua functions. See the
| `COMBAT_LOG_EVENT` | `timestamp, subevent, hideCaster, sourceGUID, sourceName, sourceFlags, sourceRaidFlags, destGUID, destName, destFlags, destRaidFlags, ...` ([details](docs/API.md#combat_log_event_unfiltered--combat_log_event-events)) |
| `COMBAT_LOG_EVENT_UNFILTERED` | same as `COMBAT_LOG_EVENT` |
| `CURSOR_CHANGED` | `isDefault, newCursorType, oldCursorType, oldCursorVirtualID` |
| `DYNAMIC_GOSSIP_POI_UPDATED` | *(none)* |
| `EQUIPMENT_SETS_CHANGED` | *(none)* |
| `EQUIPMENT_SWAP_PENDING` | `setID` |
| `EQUIPMENT_SWAP_FINISHED` | `success, setID` |
@@ -282,6 +280,8 @@ when launching with `-console`), not as Lua functions. See the
| `GLOBAL_MOUSE_DOWN` | `button` |
| `GLOBAL_MOUSE_UP` | `button` |
| `HEARTHSTONE_BOUND` | *(none)* |
| `INCOMING_RESURRECT_CHANGED` | `unitTarget` |
| `INVENTORY_SEARCH_UPDATE` | *(none)* |
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
| `LEARNED_SPELL_IN_SKILL_LINE` | `spellID, skillLineIndex, isGuildPerkSpell` |
| `LOOT_HISTORY_ROLL_CHANGED` | `itemIndex, playerIndex` |
@@ -311,9 +311,12 @@ when launching with `-console`), not as Lua functions. See the
| `PLAYER_TOTEM_UPDATE` | `totemSlot` |
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `QUEST_QUERY_COMPLETE` | *(none)* (Turtle WoW only) |
| `QUEST_REMOVED` | `questID` |
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
| `SELF_RES_SPELL_CHANGED` | *(none)* |
| `SOUNDKIT_FINISHED` | `soundHandle` (only for sounds played with `runFinishCallback`) |
| `UNIT_AURA_DELTA` | `unitTarget, updateInfo` (the instance IDs of the auras added, updated, and removed) |
| `UNIT_SPELLCAST_SENT` | `"player", target, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_START` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_STOP` | `unit, castGUID, spellID, spellName, rank` |
@@ -359,6 +362,7 @@ when launching with `-console`), not as Lua functions. See the
| Spellbook bank | `Enum.SpellBookSpellBank.{Player,Pet}` (0–1) |
| Spellbook item type | `Enum.SpellBookItemType.{None,Spell,FutureSpell,PetAction,Flyout}` (0–4; 1.12 only yields `Spell`/`PetAction`) |
| Player swing type | `Enum.PlayerSwingType.{MainHand,OffHand,Ranged}` (0–2) |
| Self-resurrect option type | `Enum.SelfResurrectOptionType.{Spell,Item}` (0–1) |
</details>
@@ -414,24 +418,30 @@ Download the prebuilt `ClassicAPI.dll` from the
3. Add `ClassicAPI.dll` to `dlls.txt`.
4. Launch the game with `VanillaFixes.exe`.
The bundled `!!!ClassicAPI` addon ships *inside* the DLL — no separate
addon download or install step needed.
The `!!!ClassicAPI` and `ClassicAPI_DebugTools` addons are *inside* the
DLL. You do not download or install them. The `src/embed/AddOns/` folder
in this repository is source code for the DLL. Do not copy it into your
game.
## Bundled addon: !!!ClassicAPI
The Lua-side companion library lives in
[`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/). It's a 1.12.1 /
[`src/embed/AddOns/!!!ClassicAPI/`](src/embed/AddOns/!!!ClassicAPI/). It's a 1.12.1 /
Lua 5.0 backport of the modern Blizzard helpers that aren't engine
functions but that consumer code still expects to find as globals —
`CallbackRegistryMixin`, `EventRegistry`, `ColorMixin` + `CreateColor`,
`Item` / `ItemLocation`, `MathUtil`(`Lerp` / `Clamp` / `CreateCounter`),
`TableUtil` (`tCompare`, `MergeTable`, `SafePack`, etc.), and `EventUtil`
(`ContinueOnAddOnLoaded` and more). It also holds the conditional slash
`TableUtil` (`tCompare`, `MergeTable`, `SafePack`, etc.), `EventUtil`
(`ContinueOnAddOnLoaded` and more), `DataProvider`, `CircularBuffer`,
`EasingUtil`, `Interpolator`, `EnumUtil`, `StringUtil`, `AnchorUtil`,
`GridLayoutUtil`, and the layout frame templates (`VerticalLayoutFrame`,
`HorizontalLayoutFrame`, `ResizeLayoutFrame`, `GridLayoutFrame`,
`StaticGridLayoutFrame`). It also holds the conditional slash
commands and `SecureCmdOptionParse`, the parser that reads `[conditions]`
and `@unit` for them.
**You don't have to install the addon manually** — the DLL embeds
the contents of [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) and
the contents of [`src/embed/AddOns/!!!ClassicAPI/`](src/embed/AddOns/!!!ClassicAPI/) and
registers them with the engine as a synthetic addon on startup, so
the library is always available when the DLL is loaded. It fires
`ADDON_LOADED`, supports `SavedVariables`, and is resolvable by name —
@@ -448,44 +458,29 @@ can't be switched off. Being hidden means it's omitted from the
index-based `GetNumAddOns()` / `GetAddOnInfo(i)` enumeration, but every
by-name lookup and dependency reference still resolves normally.
If you drop the folder into your `Interface/AddOns/` directory, **that
copy's files win** — the DLL's embedded version only serves files when
the engine's normal scan doesn't already have a newer entry under that
name. Useful for editing the Lua locally without rebuilding the DLL.
The hidden + force-enabled treatment applies either way (it's keyed to
the addon name, not to which copy serves the files), so the local copy
is also absent from the AddOns list. The dispatch is transparent:
addons consuming `Mixin`, `ColorMixin`, `TableUtil`, etc. behave
identically in both cases.
For development, you can put a copy of the addon folder in
`Interface/AddOns/` and add an empty `.classicapi-dev` file to it. The
game then reads the Lua files from that copy, so you can edit them
without a DLL rebuild. Without this file, the game uses the copy with the
newer `## Version:`. The copy is hidden and always enabled, the same as
the embedded addon.
## Bundled addon: DebugTools
## Debug tools
A 1.12.1 / Lua 5.0 backport of Blizzard's `Blizzard_DebugTools` addon
lives in [`AddOns/DebugTools/`](AddOns/DebugTools/).
It's an independent addon — it doesn't use anything ClassicAPI adds, and
ClassicAPI works fine without it. It's bundled here because it's the
natural companion for testing and debugging anything written against the
1.12 Lua surface (with or without the ClassicAPI extensions).
ClassicAPI contains a backport of Blizzard's `Blizzard_DebugTools` addon,
with the name `ClassicAPI_DebugTools`. The DLL contains this addon, so you do
not install it. The addon is not in the AddOns list, and you cannot disable it.
Slash commands provided:
The slash commands are always available. The tools load the first time
that you use one of these commands:
| Command | Purpose |
|---------|---------|
| `/dump <expr>` | Pretty-print any Lua value, including tables and multi-return tuples. The right tool for inspecting return values from `GetSpellInfo`, `C_Item.GetItemInfoInstant`, etc. |
| `/etrace` | Event tracer window. `/etrace start`, `/etrace stop`, `/etrace add EVENT`, etc. |
| `/framestack` (or `/fstack`) | Tooltip showing the frame hierarchy under the mouse cursor. |
| `/luaerrors` (or `/scripterrors`) | Lua error display window. |
Lua globals provided (backports of Blizzard helpers):
| Global | Purpose |
|--------|---------|
| `print(...)` | Backport of Blizzard's `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
| `setprinthandler(func)` / `getprinthandler()` | Install / query a custom print handler. Useful for redirecting print output in tests. |
| `tostringall(...)` | Apply `tostring()` to every vararg, preserving the count. Lua 5.0-compatible (uses `arg.n` since `select` doesn't exist in 5.0). |
To install: copy [`AddOns/DebugTools/`](AddOns/DebugTools/) into your
`Interface/AddOns/` directory like any other addon.
| `/dump <expr>` | Shows a Lua value in the chat frame. It shows tables and all return values. Use it to examine the return values of `GetSpellInfo`, `C_Item.GetItemInfoInstant`, and other functions. |
| `/etrace` | Opens the event trace window. Other forms: `/etrace start`, `/etrace stop`, `/etrace add EVENT`, and more. |
| `/framestack [showHidden [showRegions]]` (or `/fstack`) | Shows a tooltip with a list of the frames under the cursor. A `-->` mark shows one frame, with its anchors and the file and line that made it. Left Alt and Right Alt move the mark. Ctrl opens the marked frame in the table inspector. Shift shows its texture files. |
| `/tinspect [expr]` (or `/tins`) | Opens a table inspector on the result of `expr`. With no table, it opens on the frame under the cursor. The window shows the anchors and the fields of the table, grouped by type. You can edit numbers, strings and booleans, filter the lines, and open a child table or frame. |
| `/luaerrors` (or `/scripterrors`) | Opens the Lua error window. |
## Building
+42 -36
View File
@@ -5,23 +5,25 @@ Candidates for new DLL functions, prioritized. All are things the
addon polyfills badly, hackily, or not at all, and where engine access is the
right answer.
## ~~1. `C_QuestLog.IsQuestFlaggedCompleted(questID)`~~ — NOT FEASIBLE in 1.12
## ~~1. `C_QuestLog.IsQuestFlaggedCompleted(questID)`~~ — DONE (Turtle-lineage servers)
The original TODO note ("engine maintains a completed-quests bitfield in
client memory") was based on 3.3.5 / WotLK semantics. Vanilla 1.12 has no
equivalent: neither `QueryQuestsCompleted` nor `GetQuestsCompleted`
exists, the protocol has no `CMSG_QUERY_QUESTS_COMPLETED`, and the
client doesn't track historical completions. The polyfill at
`Util/C_QuestLog.lua:5-8` works on Frostmourne (3.3.5a) where those
functions exist; on the 1.12 Octo client they're absent.
Stock 1.12 has no completed-quests query and keeps no quest history, so
this exists only where the server offers the list another way. Turtle WoW
and its forks answer the player command `.queststatuses` with `TWQUEST`
guild addon messages from the player, split at 2000 characters, the last
message always shorter (probed: 2003, 2004, 0) - an exact end marker.
Servers that want to expose this in 1.12 (notably Turtle WoW) do it
via the `CHAT_MSG_ADDON` channel — the addon sends `.queststatus` over
guild/whisper chat and the server replies with addon-channel messages
the addon parses. Fundamentally async, server-specific, and requires
SavedVariables-style persistence across sessions to amortize the
round-trip — all of which belong on the Lua side, not in this DLL.
There's nothing the DLL can usefully add here.
[src/quest/Completed.cpp](src/quest/Completed.cpp) does it all in the DLL,
Turtle clients only: it sends the request itself (on
SMSG_LOGIN_VERIFY_WORLD, after each turn-in, and from
`QueryQuestsCompleted`), reads every answer at the packet funnel (sender
must be the player), and keeps the live list behind `GetQuestsCompleted`,
`C_QuestLog.IsQuestFlaggedCompleted` and
`C_QuestLog.GetAllCompletedQuestIDs`; `QUEST_QUERY_COMPLETE` fires per
answer. No disk cache: the first answer lands one round trip after
PLAYER_ENTERING_WORLD, because the server sends the player object (which
fires PLAYER_LOGIN) before it reads any request. Replaces the closed Lua
collector of PR #86.
## ~~2. `C_Spell.GetSpellDescription(spellID)`~~ — DONE
@@ -455,8 +457,7 @@ the other spell registrations in [src/spell/Info.cpp](src/spell/Info.cpp).
## ~~26. `C_Item.IsEquippableItem(item)`~~ — DONE
Cache-record read of `m_inventoryType` at `OFF_ITEMSTATS_INVENTORY_TYPE`.
Non-zero = equippable. Lives in `item/Equipment.cpp` alongside
`OffhandHasWeapon` (which uses the same field) and `IsEquippedItem`.
Non-zero = equippable. Lives in `item/Equipment.cpp` alongside `IsEquippedItem`.
Synchronous; returns `false` on cache miss without firing a load.
Namespaced under `C_Item` to match Classic Era 1.15.x.
@@ -1465,20 +1466,12 @@ it on a struct pointer to scan layouts, or from Lua via
`_classicapi_HexDump(label, addr, len)` to inspect engine
globals without a per-investigation probe module.
## ~~56. `OffhandHasWeapon()`~~ — DONE
## ~~56. `OffhandHasWeapon()`~~ — DROPPED
Shipped as `src/item/Equipment.cpp`. Resolves the off-hand
equipment slot (slot 17) via `Item::Location::ResolveEquipmentSlot`,
reads the cached `ItemStats_C` record's `m_inventoryType` at
`+0x2C`, and returns true iff it's `INVTYPE_WEAPON` (13) or
`INVTYPE_WEAPONOFFHAND` (22). Shields, holdables (tomes/orbs),
empty slots, and uncached items all return false.
No async load is fired — modern API behavior matches; if the
off-hand item isn't cached the function silently returns false
until something else warms the cache. Typically only matters on
first login before the player's own gear is in the cache, which
in practice is loaded eagerly anyway.
Vanilla already ships it (`0x004C9570`, same body as 3.3.5/4.3.4:
off-hand slot item class 2 → `1`, else `nil`). Our override returned
booleans and tested inventory type instead; removed so the engine's
own global stands.
## 57. `IsFlying()` — TESTED, DROPPED
@@ -1952,8 +1945,8 @@ current item/spell/unit. Approach:
4. Each match reveals the offset for that state field.
Once located, `GetItem()`, `GetSpell()`, `GetUnit()` are trivial
deref+push. Implementation roughly the same as `OffhandHasWeapon`
or `IsEquippedItem` patterns.
deref+push. Implementation roughly the same as the `IsEquippedItem`
pattern.
### Risks
@@ -3818,7 +3811,7 @@ of descriptors feeds the in-game browser, a page generator, and a drift check.
registration on the first pass of each Lua state, derives systems, and
exports `C_APIDocumentation.GetSystems()` / `GetSystem(name)` lazily in
Blizzard's exact table shape, plus `_classicapi_UndocumentedAPI()`.
- `AddOns/!!!ClassicAPI/APIDocumentation/` — the 8-file port of
- `src/embed/AddOns/!!!ClassicAPI/APIDocumentation/` — the 8-file port of
`Blizzard_APIDocumentation`, driven by `/classicapi` (short `/capi`).
Named for what it documents: the browser holds only ClassicAPI's own
surface, so `/api` would have over-promised.
@@ -4088,9 +4081,22 @@ Each entry still needs the usual check of how the engine does it before building
`GetRotatesTexture` (`CSimpleStatusBar.cpp:135`, the vertical texcoord path
at `:285`). Retail adds `SetFillStyle` (STANDARD / CENTER / REVERSE),
`SetColorFill`, `SetStatusBarDesaturated` / `Desaturation`.
- **ScrollingMessageFrame** `SetInsertMode` / `GetInsertMode`,
`GetMessageInfo`, `RemoveMessagesByAccessID`, `RemoveMessagesByExtraData`,
`SetScrollOffset` — in C in 4.3.4 (retail moved the type to Lua).
- **DONE** (`src/scrollingmessageframe/`, retail's shapes).
**ScrollingMessageFrame** `GetMessageInfo`, `SetScrollOffset` (1.12's
`SetScrollFromBottom`, 0x00793070, under its later name, clamped as
retail), `GetScrollOffset`, `GetMaxScrollRange`, `ScrollByAmount`,
`AddOnDisplayRefreshedCallback`, `SetInsertMode` / `GetInsertMode` (TOP
through a redirect of the layout's one line-anchor call, 0x00788975),
`BackFillMessage` (writes 4.3.4's addToTop slot of the ring, the free one
before the oldest; a full frame drops it, as retail's CircularBuffer
`PushBack` does — 4.3.4 overwrites the oldest there).
- **DONE** (`src/scrollingmessageframe/MessageInfo.cpp`).
**ScrollingMessageFrame** `RemoveMessagesByPredicate` (retail's shape; it
covers 4.3.4's `RemoveMessagesByAccessID` / `RemoveMessagesByExtraData`,
whose int fields our retail-shaped records do not have). The compaction
is 4.3.4's `RemoveLines` loop over 1.12's ring, then the engine's own
layout (0x00788750) at the kept scroll offset and re-arm (0x00788B80),
or Clear (0x007882B0) when nothing is kept.
- **DONE** (`src/editbox/Focus.cpp`, `src/animation/Framerate.cpp`).
**Globals** (`SimpleFrameScript_Scripts.cpp`): `GetCurrentKeyBoardFocus()`
(the focused edit box's wrapper, or nil); `SetMaxAnimFramerate(fps)` /
+63 -22
View File
@@ -1,38 +1,59 @@
# embed_resources.cmake — embed the addon and the bindings XML as Win32
# embed_resources.cmake — embed the addons and the bindings XML as Win32
# RCDATA resources.
#
# Runs at configure time. It writes two files into OUT_DIR:
#
# embedded.rc one `<id> RCDATA "<path>"` line per file; rc.exe
# reads each file straight from disk.
# reads each file's gzipped copy from disk.
# embedded_resources.h the IDs the DLL looks up at runtime through
# src/embed/Resource.h:
#
# namespace Embed::Ids { constexpr int kBindingsXml = ...; }
# namespace Addons::Embedded::ClassicAPIFiles {
# namespace Addons::Embedded::Manifest {
# constexpr const char *kAddons[] = { ... }; // in ADDONS order
# constexpr size_t kAddonCount = ...;
# struct File { const char *path; int resourceId; };
# constexpr File kFiles[] = { ... };
# constexpr size_t kFileCount = ...;
# }
#
# Addon paths are relative to the addon folder, in backslash form
# (`Util\Print.lua`) — the suffix the engine builds after
# `Interface\AddOns\!!!ClassicAPI\`. The DLL compares them ignoring case.
# File paths are relative to ADDONS_ROOT, in backslash form
# (`!!!ClassicAPI\Util\Print.lua`) — the suffix the engine builds after
# `Interface\AddOns\`. The DLL compares them ignoring case.
#
# The `.toc` is the one file that is not embedded verbatim: its
# Each addon's `.toc` is the one file that is not embedded verbatim: its
# `## Version:` line is stamped with ADDON_VERSION into a copy under
# OUT_DIR, so a release build is a pure function of `-DCLASSICAPI_TAG` and
# the source toc stays `DEV`.
# the source tocs stay `DEV`. The developer marker `.classicapi-dev` is
# never embedded: it must reflect on-disk presence only (see
# DiskHasDevMarker in src/addons/Embedded.cpp).
#
# Every resource is gzipped at build time (gzip_file.cmake) into
# `OUT_DIR/embed/<path>.gz`, and the DLL inflates it with the engine's zlib on
# first use (src/embed/Resource.cpp). The addon text compresses to about a
# quarter of its size.
#
# Rebuild triggers: adding or removing a file reconfigures through the
# caller's CONFIGURE_DEPENDS glob; editing the toc reconfigures through
# CMAKE_CONFIGURE_DEPENDS; editing any other file recompiles the .rc
# through its OBJECT_DEPENDS.
# CONFIGURE_DEPENDS glob; editing a toc reconfigures through
# CMAKE_CONFIGURE_DEPENDS; editing any other file re-gzips it through its
# custom command, which recompiles the .rc through its OBJECT_DEPENDS.
#
# Usage:
# embed_resources(TARGET <t> ADDON_DIR <dir> ADDON_FILES <rel paths...>
# embed_resources(TARGET <t> ADDONS_ROOT <dir> ADDONS <folder names...>
# ADDON_VERSION <v> BINDINGS_XML <file> OUT_DIR <dir>)
set(_EMBED_GZIP_SCRIPT "${CMAKE_CURRENT_LIST_DIR}/gzip_file.cmake")
# Add the build-time command that gzips `src` into `gz`.
function(_embed_gzip src gz)
add_custom_command(
OUTPUT "${gz}"
COMMAND "${CMAKE_COMMAND}" "-DIN=${src}" "-DOUT=${gz}"
-P "${_EMBED_GZIP_SCRIPT}"
DEPENDS "${src}" "${_EMBED_GZIP_SCRIPT}"
VERBATIM)
endfunction()
# Write `content` to `path` only when it differs, so an unchanged
# reconfigure does not touch the file and trigger a rebuild.
function(_embed_write_if_changed path content)
@@ -43,23 +64,33 @@ function(_embed_write_if_changed path content)
endfunction()
function(embed_resources)
cmake_parse_arguments(E "" "TARGET;ADDON_DIR;ADDON_VERSION;BINDINGS_XML;OUT_DIR"
"ADDON_FILES" ${ARGN})
cmake_parse_arguments(E "" "TARGET;ADDONS_ROOT;ADDON_VERSION;BINDINGS_XML;OUT_DIR"
"ADDONS" ${ARGN})
set(rc "// Generated by cmake/embed_resources.cmake - do not edit.\n\n")
set(addon_names "")
set(manifest "")
set(inputs "")
set(id 1)
string(APPEND rc "${id} RCDATA \"${E_BINDINGS_XML}\"\n")
list(APPEND inputs "${E_BINDINGS_XML}")
set(gz "${E_OUT_DIR}/embed/Bindings.xml.gz")
_embed_gzip("${E_BINDINGS_XML}" "${gz}")
string(APPEND rc "${id} RCDATA \"${gz}\"\n")
list(APPEND inputs "${gz}")
set(bindings_id ${id})
math(EXPR id "${id} + 1")
set(files ${E_ADDON_FILES})
set(files "")
foreach(addon ${E_ADDONS})
string(APPEND addon_names " \"${addon}\",\n")
file(GLOB_RECURSE addon_files CONFIGURE_DEPENDS RELATIVE
"${E_ADDONS_ROOT}" "${E_ADDONS_ROOT}/${addon}/*")
list(FILTER addon_files EXCLUDE REGEX "(^|/)\\.classicapi-dev$")
list(APPEND files ${addon_files})
endforeach()
list(SORT files) # deterministic IDs
foreach(rel ${files})
set(abs "${E_ADDON_DIR}/${rel}")
set(abs "${E_ADDONS_ROOT}/${rel}")
if(rel MATCHES "\\.toc$")
file(READ "${abs}" toc)
string(REGEX REPLACE "## Version:[^\n\r]*"
@@ -69,8 +100,10 @@ function(embed_resources)
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${abs}")
set(abs "${stamped}")
endif()
string(APPEND rc "${id} RCDATA \"${abs}\"\n")
list(APPEND inputs "${abs}")
set(gz "${E_OUT_DIR}/embed/${rel}.gz")
_embed_gzip("${abs}" "${gz}")
string(APPEND rc "${id} RCDATA \"${gz}\"\n")
list(APPEND inputs "${gz}")
string(REPLACE "/" "\\\\" rel_bs "${rel}")
string(APPEND manifest " { \"${rel_bs}\", ${id} },\n")
math(EXPR id "${id} + 1")
@@ -90,7 +123,13 @@ namespace Embed::Ids {
constexpr int kBindingsXml = ${bindings_id};
} // namespace Embed::Ids
namespace Addons::Embedded::ClassicAPIFiles {
namespace Addons::Embedded::Manifest {
// Registration order: the first one registers, and so loads, first.
constexpr const char *kAddons[] = {
${addon_names}};
constexpr size_t kAddonCount = sizeof(kAddons) / sizeof(kAddons[0]);
struct File {
const char *path;
@@ -102,9 +141,11 @@ ${manifest}};
constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]);
} // namespace Addons::Embedded::ClassicAPIFiles
} // namespace Addons::Embedded::Manifest
")
add_custom_target(${E_TARGET}_embed DEPENDS ${inputs})
add_dependencies(${E_TARGET} ${E_TARGET}_embed)
target_sources(${E_TARGET} PRIVATE "${E_OUT_DIR}/embedded.rc")
set_source_files_properties("${E_OUT_DIR}/embedded.rc" PROPERTIES
OBJECT_DEPENDS "${inputs}")
+8
View File
@@ -0,0 +1,8 @@
# gzip_file.cmake — gzip one file at build time, for embed_resources.cmake.
#
# cmake -DIN=<file> -DOUT=<file.gz> -P gzip_file.cmake
#
# The raw format holds the one file's bytes with no archive entry around
# them, so OUT is a plain gzip stream of IN.
file(ARCHIVE_CREATE OUTPUT "${OUT}" PATHS "${IN}" FORMAT raw
COMPRESSION GZip COMPRESSION_LEVEL 9)
+2906 -305
View File
File diff suppressed because it is too large Load Diff
+22 -4
View File
@@ -86,9 +86,11 @@ The DLL hooks the engine's own text pipeline — no companion addon:
(`49934b0`, `b9d0705`).
- **Chat fade** — icons fade in lockstep with their chat line (`3ca3b7e`).
- **Edit boxes** — render the icons in their text, as 4.3.4 does; the caret,
the mouse and the arrow keys treat an icon as one character, and Ctrl+C
drops icons (4.3.4's 0x1500 strip mask). Typed markup stays literal by the
engine's own input escape (below).
the mouse and the arrow keys treat an icon as one character, Ctrl+C
drops icons (4.3.4's 0x1500 strip mask), and a multi-line box's caret,
selection and click rows follow the lines a tall icon moves (below;
4.3.4's own rows do not). Typed markup stays literal by the engine's own
input escape (below).
- **Payload** — the full
`|Tpath:height:width:offsetX:offsetY:texW:texH:left:right:top:bottom:r:g:b|t`:
size, pen offset, sprite-sheet crop, and vertex tint. BLP and uncompressed
@@ -377,7 +379,7 @@ funnel for exactly 4 callers:
- `Script_GetStringWidth` (0x0079E510)
- the GameTooltip auto-size (0x00530640)
- the editbox caret positioner (0x0077DE70)
- the editbox selection-piece placer (0x0077DE70, a whole last line's width)
- the layout effective-width vmethod (`FUN_00772930` = fs+0x24 vtbl +0x1C,
which feeds auto-width fontstring layout)
@@ -523,6 +525,22 @@ the same input escape, so ClassicAPI's edit boxes need no special case:
icon with the following character into one step; ours is its own step.
- **Caret x** — `FUN_0077DA80` measures the line through
`FUN_00772AE0`, whose co-hook adds the icons.
- **Rows (multi-line)** — `PlaceCaret` (`0x77da80`), `PlaceSelectionPiece`
(`0x77de70`) and `GetIndexAtPoint` (`0x77d0d0`) step rows of one height
(font height + spacing, pixel-aligned), as 4.3.4's `UpdateVisibleCursor`,
`UpdateHighlightArea` and `ConvertCoordinateToIndex` do — while the render
moves a tall-icon line and every line after it down by the overflow
(`InitializeText`; the emitter co-hook) and the box grows to match (the
string-height co-hook). 4.3.4 has that fault too; `EditBox::IconRows`
(`src/editbox/IconRows.cpp`) gives each placer the render's move, from
`Text::InlineTexture::LineIconShift` over the box's own line table: the
click's y is moved onto the uniform row of the icon-aware row under it
before the engine maps it; a selection piece is re-anchored after the
engine placed it, with the engine's own x; the caret likewise, then
`OnCursorChanged` fired once with the moved y through
`FUN_FRAME_RUN_SCRIPT_VARIADIC` exactly as `PlaceCaret` fires it (its own
fire muted for the call by emptying the slot), so
`ScrollingEditBoxMixin:ScrollCursorIntoView` scrolls to the real caret.
- **Click → cursor, single-line window** — `GetIndexAtPoint` (`0x77d0d0`),
`RebuildLabel` and `MeasureWindow` count characters with
`FUN_FONTSTRING_FIT_TEXT` (`0x772bc0` → `0x44d860` → `0x5c6c50`), whose
Vendored Submodule
+1
+17 -1
View File
@@ -48,8 +48,24 @@ bool RedirectCall(uintptr_t site, uintptr_t expected, const void *target) {
return PatchBytes(reinterpret_cast<uint8_t *>(site) + 1, &rel, sizeof rel);
}
bool RedirectJcc(uintptr_t site, uintptr_t expected, const void *target) {
const auto *code = reinterpret_cast<const uint8_t *>(site);
if (code[0] != 0x0F || (code[1] & 0xF0) != 0x80)
return false;
const auto dest = [site](int32_t rel) { return site + 6 + static_cast<uintptr_t>(rel); };
int32_t rel = 0;
std::memcpy(&rel, code + 2, sizeof rel);
const uintptr_t to = reinterpret_cast<uintptr_t>(target);
if (dest(rel) == to)
return true;
if (dest(rel) != expected)
return false;
rel = static_cast<int32_t>(to - (site + 6));
return PatchBytes(reinterpret_cast<uint8_t *>(site) + 2, &rel, sizeof rel);
}
bool ReplaceWithCall(uintptr_t site, const uint8_t *original, size_t len, const void *target) {
uint8_t code[16];
uint8_t code[64];
if (len < 5 || len > sizeof code)
return false;
code[0] = 0xE8;
+6 -2
View File
@@ -24,8 +24,12 @@ bool PatchBytes(void *dst, const void *src, size_t len);
// Idempotent, so it can run on every registration pass.
bool RedirectCall(uintptr_t site, uintptr_t expected, const void *target);
// Writes `call target` plus NOP padding over the `len`-byte (>= 5)
// instruction at `site` when it still holds `original`; true when the site
// RedirectCall for a `0F 8x rel32` conditional jump: points the taken branch
// at `site` to `target` when it still reaches `expected`. Same contract.
bool RedirectJcc(uintptr_t site, uintptr_t expected, const void *target);
// Writes `call target` plus NOP padding over the `len` bytes (5 to 64) of
// code at `site` when they still hold `original`; true when the site
// now calls `target`. A site holding anything else (another DLL's patch, a
// different build) is left alone. Idempotent, like RedirectCall.
bool ReplaceWithCall(uintptr_t site, const uint8_t *original, size_t len, const void *target);
+2 -4
View File
@@ -82,13 +82,11 @@ static void __stdcall LoadGlueScriptFunctions_h() {
// Every Lua-side `frame:RegisterEvent(...)` is a chance to claim a slot
// for any custom event still waiting. By this point the engine's table
// is fully populated and SuperWoWhook / other DLLs have done their
// post-rebuild writes, so the table state is settled and our backwards
// walk finds genuine NULL slots near the tail.
// post-rebuild writes, so the table state is settled and our walk from the
// low end finds genuinely free slots.
static void __fastcall FrameRegisterEvent_h(void *frame, void *edx,
const char *eventName) {
Event::Custom::RetryClaims();
// Throttled-internally; no-op when the cache or scan toggle is off.
Player::NameCache::Tick();
FrameRegisterEvent_o(frame, edx, eventName);
}
+33 -4
View File
@@ -40,6 +40,16 @@ double InternalToPixel(float internal) {
return static_cast<double>(internal) * div * Offsets::UI_COORD_SCALE_UNIT / mul;
}
bool StringToFlag(const char *text) {
using StringToFlag_t = int(__fastcall *)(const char *text);
return reinterpret_cast<StringToFlag_t>(Offsets::FUN_XML_ATTR_TO_BOOL)(text) != 0;
}
int StrCmpI(const char *a, const char *b, int n) {
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
return reinterpret_cast<SStrCmpI_t>(Offsets::FUN_SSTR_CMP_I)(a, b, n);
}
namespace Lua {
// Each entry binds a typed function pointer in `Game::Lua::` to the
// corresponding raw VA in `Offsets`. The X-macro keeps the column-aligned
@@ -85,7 +95,10 @@ namespace Lua {
F(Yield, lua_yield, LUA_YIELD) \
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
F(SetN, luaL_setn, LUAL_SETN) \
F(CheckStack, lua_checkstack, LUA_CHECK_STACK)
F(Ref, luaL_ref, LUA_REF_REF) \
F(Unref, luaL_unref, LUA_REF_UNREF) \
F(CheckStack, lua_checkstack, LUA_CHECK_STACK) \
F(Concat, lua_concat, LUA_CONCAT)
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
@@ -131,11 +144,21 @@ void PushObject(void *L, void *object) {
RawGetI(L, REGISTRY_INDEX, ObjectLuaRef(object));
}
const char *ObjectDisplayName(void *object) {
bool ToFlag(void *L, int index, bool defaultValue) {
// Returns a full int in EAX on every path (Ghidra's `bool` is wrong).
using ToFlag_t = int(__fastcall *)(void *L, int index, int defaultValue);
return reinterpret_cast<ToFlag_t>(Offsets::FUN_LUA_TO_BOOLEAN_LOOSE)(L, index,
defaultValue ? 1 : 0) != 0;
}
const char *ObjectName(void *object) {
using GetName_t = const char *(__thiscall *)(void *);
const void *vtable = Game::Read<const void *>(object, 0);
const char *name = Game::Read<GetName_t>(
vtable, Offsets::VTBL_SCRIPTOBJECT_GET_NAME)(object);
return Game::Read<GetName_t>(vtable, Offsets::VTBL_SCRIPTOBJECT_GET_NAME)(object);
}
const char *ObjectDisplayName(void *object) {
const char *name = ObjectName(object);
return name != nullptr ? name
: reinterpret_cast<const char *>(Offsets::VAR_STR_UNNAMED);
}
@@ -415,6 +438,12 @@ void SetFieldBool(void *L, const char *key, bool value) {
SetTable(L, -3);
}
void SetFieldNil(void *L, const char *key) {
PushString(L, key);
PushNil(L);
SetTable(L, -3);
}
void SetGlobalNumber(void *L, const char *name, double value) {
PushString(L, name);
PushNumber(L, value);
+41 -1
View File
@@ -104,6 +104,19 @@ using LoadScriptFunctions_t = void(__fastcall *)();
// registering its 109 glue functions. See FUN_LOAD_GLUE_SCRIPT_FUNCTIONS.
using LoadGlueScriptFunctions_t = void(__stdcall *)();
// The engine's text-to-flag reader (FUN_XML_ATTR_TO_BOOL, 4.3.4's
// `StringToBOOL(const char *)`), which XML boolean attributes go through:
// true for text starting 1-9/T/Y/t/y or equal to "on"/"enabled" (any case),
// false for anything else, including null.
bool StringToFlag(const char *text);
// Storm's case-insensitive compare, SStrCmpI (FUN_SSTR_CMP_I) — the one the
// engine uses for names, tokens and tags: at most `n` characters, ASCII case
// folded, <0 / 0 / >0 like strcmp. The default `n` is Storm's "whole string".
// Always call it through here: the engine function is `__stdcall` (RET 0xC),
// and a `__cdecl` declaration drifts ESP by 12 per call.
int StrCmpI(const char *a, const char *b, int n = 0x7FFFFFFF);
// --- API documentation descriptors -----------------------------------------
//
// Every Lua registration (see the `Register*` functions in `Game::Lua`) can
@@ -296,7 +309,16 @@ 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);
// Pops the top value into the table at `t` under a fresh integer key and
// returns the key (LUA_REFNIL, -1, for a nil). With REGISTRY_INDEX it anchors
// a value across calls; read it back with RawGetI(L, REGISTRY_INDEX, ref).
using luaL_ref_t = int(__fastcall *)(void *L, int t);
// Releases a key returned by luaL_ref; ignores LUA_REFNIL / LUA_NOREF.
using luaL_unref_t = void(__fastcall *)(void *L, int t, int ref);
using lua_checkstack_t = int(__fastcall *)(void *L, int size);
// Pops the top `n` values and pushes their `..` concatenation; n == 0 pushes
// "" and n == 1 leaves the value untouched.
using lua_concat_t = void(__fastcall *)(void *L, int n);
extern const lua_isnumber_t IsNumber;
extern const lua_isstring_t IsString;
@@ -312,6 +334,13 @@ extern const lua_pushboolean_t PushBoolean;
// engine's `lua_pushboolean` ABI wants `int` (any non-zero = true), so
// this just funnels through the raw binding with a single conversion.
inline void PushBool(void *L, bool b) { PushBoolean(L, static_cast<int>(b)); }
// The engine's script-flag argument reader (FUN_LUA_TO_BOOLEAN_LOOSE, 4.3.4's
// `StringToBOOL(L, index, default)`), which every engine method taking an
// optional flag uses: nil is false, a boolean is itself, a number is its
// truncated int (so 0.5 is false), a string reads as StringToFlag except that
// text it does not recognize is `defaultValue`, and any other type is
// `defaultValue`.
bool ToFlag(void *L, int index, bool defaultValue = false);
extern const lua_pushstring_t PushString;
extern const lua_pushlstring_t PushLString;
extern const lua_pushvalue_t PushValue;
@@ -343,7 +372,10 @@ 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 luaL_ref_t Ref;
extern const luaL_unref_t Unref;
extern const lua_checkstack_t CheckStack;
extern const lua_concat_t Concat;
// Returns the global `lua_State *` (read on demand from the engine's global).
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
@@ -366,8 +398,12 @@ int ObjectLuaRef(void *object);
// Pushes the object's Lua table (registry[ObjectLuaRef(object)]).
void PushObject(void *L, void *object);
// The object's global name (VTBL_SCRIPTOBJECT_GET_NAME), or nullptr when it
// has none.
const char *ObjectName(void *object);
// How the engine names an object in its frame-script usage errors:
// `GetName() ?: "<unnamed>"` (VTBL_SCRIPTOBJECT_GET_NAME).
// `GetName() ?: "<unnamed>"`.
const char *ObjectDisplayName(void *object);
// Same resolve, then the type gate every engine frame method applies to its
@@ -505,6 +541,10 @@ void RegisterIntegerEnum(const char *parent, const char *sub,
void SetFieldNumber(void *L, const char *key, double value);
void SetFieldString(void *L, const char *key, const char *value);
void SetFieldBool(void *L, const char *key, bool value);
// `t[key] = nil` — clears a field a reused (caller-supplied) table may still
// hold from an earlier fill. Not for fresh tables: Lua 5.0 inserts a node for
// a missing key even when the value is nil.
void SetFieldNil(void *L, const char *key);
// Sets `_G[name] = value` via `lua_rawset` on the globals
// pseudo-index. `RawSet` over `SetTable` because the globals table has
+1388 -84
View File
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
// 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/>.
// `IsItemAction(slot)` / `HasBonusActionBar()` — the action-bar predicates
// from the newer clients' ActionBarFrame_Scripts, read off the same engine
// state the 1.12 readers use.
//
// IsItemAction 1 when the 1-based slot holds an item, else nil; a
// non-number slot is a usage error. The item kind is
// the 0x80000000 tag in VAR_ACTION_TABLE.
// HasBonusActionBar 1 while a bonus bar is active, else nil: the offset
// GetBonusBarOffset returns is nonzero.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Action::Bar {
namespace {
int PushFlag(void *L, bool on) {
if (on)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
int __fastcall Script_IsItemAction(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: IsItemAction(slot)");
return 0;
}
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS)
return PushFlag(L, false);
const auto *table = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_ACTION_TABLE));
return PushFlag(L, (table[slot0] & Offsets::ACTION_TYPE_MASK) ==
Offsets::ACTION_TYPE_ITEM_BY_ID);
}
int __fastcall Script_HasBonusActionBar(void *L) {
return PushFlag(L, Game::Read<uint32_t>(Offsets::VAR_BONUS_BAR_OFFSET) != 0);
}
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kSlotArgs[] = {
Game::Doc::Req("slot", "number", "The action slot, 1 to 120."),
};
const Game::Doc::Field kIsItemRets[] = {
Game::Doc::Opt("isItem", "number", nullptr, "1 if the slot holds an item, else nil."),
};
const Game::Doc::Function kIsItemAction{
"Returns 1 if an action slot holds an item.",
kSlotArgs, kIsItemRets, "ActionBar"};
const Game::Doc::Field kHasBonusRets[] = {
Game::Doc::Opt("hasBonusBar", "number", nullptr,
"1 if a bonus action bar is active, else nil."),
};
const Game::Doc::Function kHasBonusActionBar{
"Returns 1 if a bonus action bar is active, for example in a stance or form.",
{}, kHasBonusRets, "ActionBar"};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsItemAction", &Script_IsItemAction, &kIsItemAction);
Game::Lua::RegisterGlobalFunction("HasBonusActionBar", &Script_HasBonusActionBar,
&kHasBonusActionBar);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Action::Bar
+110
View File
@@ -0,0 +1,110 @@
// 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/>.
// The range answer of an action button whose macro is aimed at a unit.
//
// 4.3.4 keeps that unit on the macro (MACRONODE::m_target: the `[@unit]` of
// the clause its display resolved from, or the target SetMacroSpell /
// SetMacroItem named), and CGActionBar::HasValidTarget range-checks the slot's
// spell against it in place of the current target. 1.12's slot range function
// FUN_ACTION_SLOT_RANGE already takes the unit as a GUID for its range check,
// and always passes 0, the selection. `Macro::ShowTooltip` keeps the unit (its
// `Info::unit`), and this answers for those slots with that GUID, in the
// engine's order: the range check, then the implicit-target test, where the
// unit standing in for the selection must exist. Every other slot goes to the
// engine function unchanged.
//
// The function's one caller, Script_IsActionInRange, is redirected rather than
// the function hooked.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "action/Slot.h"
#include "macro/ShowTooltip.h"
#include "object/Resolve.h"
#include "spell/Lookup.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Action::Range {
namespace {
using SlotRange_t = int(__fastcall *)(int slot0, int *inRange);
using SlotToSpell_t = int(__fastcall *)(int slot0, uint32_t *outPetFlag);
using CheckRange_t = uint8_t(__fastcall *)(const void *caster, const uint8_t *record,
uint32_t guidLo, uint32_t guidHi, uint32_t report);
// The first implicit targets the engine answers for: case 0 of its table.
bool TakesUnitTarget(int32_t implicitTarget) {
switch (implicitTarget) {
case 6: case 21: case 23: case 25: case 26: case 35: case 45: case 57: case 61:
return true;
default:
return false;
}
}
uint64_t MacroUnit(int slot0) {
const uint32_t macroID = Action::Slot::MacroIDForSlot(slot0);
Macro::ShowTooltip::Info info{};
// Passive: this runs inside a Lua call, from a button's OnUpdate.
if (macroID == 0 || !Macro::ShowTooltip::LookupPassive(macroID, &info))
return 0;
return info.unit;
}
int __fastcall SlotRange_h(int slot0, int *inRange) {
const auto original = reinterpret_cast<SlotRange_t>(Offsets::FUN_ACTION_SLOT_RANGE);
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS)
return original(slot0, inRange);
const uint64_t unit = MacroUnit(slot0);
if (unit == 0)
return original(slot0, inRange);
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return 0;
uint32_t pet = 0;
const int spellID =
reinterpret_cast<SlotToSpell_t>(Offsets::FUN_ACTION_SLOT_TO_SPELL)(slot0, &pet);
const uint8_t *record = ::Spell::Lookup::RecordForID(spellID);
if (record == nullptr || pet != 0)
return 0;
if (!reinterpret_cast<CheckRange_t>(Offsets::FUN_CHECK_SPELL_TARGET_RANGE)(
player, record, static_cast<uint32_t>(unit), static_cast<uint32_t>(unit >> 32), 0)) {
*inRange = 0;
return 1;
}
const int32_t implicitTarget =
Game::Read<int32_t>(record, Offsets::OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_A);
if (!TakesUnitTarget(implicitTarget) || Object::ByGuid(Offsets::TYPEMASK_UNIT, unit) == nullptr)
return 0;
*inRange = 1;
return 1;
}
void Register() {
Common::RedirectCall(Offsets::CALL_IS_ACTION_IN_RANGE_SLOT_RANGE,
Offsets::FUN_ACTION_SLOT_RANGE,
reinterpret_cast<const void *>(&SlotRange_h));
}
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
} // namespace Action::Range
+147 -112
View File
@@ -11,19 +11,26 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Embedded `!!!ClassicAPI` addon fallback. When the user doesn't have
// the addon installed on disk, this module makes the engine think it
// does — without writing anything to the filesystem.
// Embedded addons. When the user doesn't have an addon installed on disk,
// this module makes the engine think they do — without writing anything
// to the filesystem. The addons are `Manifest::kAddons` (the `ADDONS`
// list in CMakeLists.txt): `!!!ClassicAPI`, which loads at login, and
// `ClassicAPI_DebugTools`, which is `## LoadOnDemand` behind the
// `/dump` / `/fstack` / `/etrace` stubs in `!!!ClassicAPI` — retail's
// chat-frame + Blizzard_DebugTools split. (The `Blizzard_` name is not
// available to us: the engine moves loose `Interface\AddOns\Blizzard*`
// folders aside at startup, `MoveAsideLooseInterface`, and refuses a
// `Blizzard_*` TOC without a signature.)
//
// How:
//
// 1. The CMake build embeds every file under `AddOns/!!!ClassicAPI/`
// as an RCDATA resource, plus a generated `{path, resourceId}`
// manifest (`embedded_resources.h`).
// 1. The CMake build embeds every file of every listed addon as an
// RCDATA resource, plus a generated `{path, resourceId}` manifest
// (`embedded_resources.h`).
//
// 2. We hook the engine's file-read function at `FUN_FILE_READ`.
// When the engine asks for a path under
// `Interface\AddOns\!!!ClassicAPI\`, we allocate a Storm buffer
// `Interface\AddOns\<embedded addon>\`, we allocate a Storm buffer
// via the same allocator the engine itself uses, copy the
// embedded content in, and hand it back. The caller's normal
// `SMemFree` reclaims it on completion — no special lifetime.
@@ -35,13 +42,16 @@
// embedded `<Script file="sub\x.lua">`.
//
// 4. We pre-hook the engine's `FUN_ADDON_SCAN_FULL` and call the TOC
// parser with `"!!!ClassicAPI"` before the scan runs. The registry
// parser with each embedded name before the scan runs. The registry
// is empty there (the login init resets it just before), so our
// entry is the first record and the load pass — which walks the
// records in registration order — loads it first. The scan's own
// `## LoadWith:` pass then sees it like any disk addon. The parser
// is dedup-safe: when the user also has the addon on disk, the
// scan's call for that folder hits the hash table and is a no-op.
// entries are the first records and the load pass — which walks the
// records in registration order — loads `!!!ClassicAPI` first. The
// scan's own `## LoadWith:` pass then sees them like any disk addon.
// The parser is dedup-safe: when the user also has an addon on disk,
// the scan's call for that folder hits the hash table and is a no-op.
//
// Every embedded addon is hidden from the addon list and force-enabled,
// the way Blizzard's own addons are always available and never listed.
//
// Same pattern WeirdUtils.dll uses (we reverse-engineered it from
// `0x10015A74` while figuring this out). The addon load pass then
@@ -53,6 +63,7 @@
#include "Game.h"
#include "Offsets.h"
#include "addons/Embedded.h"
#include "addons/EngineIO.h"
#include "addons/FlavorBindings.h"
#include "addons/FlavorToc.h"
@@ -70,23 +81,23 @@ namespace Addons::Embedded {
namespace {
constexpr const char *kAddonName = "!!!ClassicAPI";
constexpr const char *kAddonTocFile = "!!!ClassicAPI.toc";
using Manifest::kAddonCount;
using Manifest::kAddons;
// Developer marker. When this file exists in the on-disk addon folder,
// the disk copy wins unconditionally (see `DiskHasDevMarker` /
// `DecideSource`). It is gitignored, excluded from the embed, and the
// release ships only the DLL — so it can only exist where a developer
// deliberately created it, never in a normal user's install.
// Developer marker. When this file exists in an on-disk addon folder,
// the disk copy of that addon wins unconditionally (see
// `DiskHasDevMarker` / `DecideSource`). It is gitignored, excluded from
// the embed, and the release ships only the DLL — so it can only exist
// where a developer deliberately created it, never in a normal user's
// install.
constexpr const char *kDevMarkerFile = ".classicapi-dev";
// `Interface\AddOns\!!!ClassicAPI\` — the prefix the engine
// constructs for any of our addon's files. Comparing case-
// `Interface\AddOns\` — the prefix the engine constructs for any addon
// file; the manifest paths are relative to it. Comparing case-
// insensitively because the TOC parser uses `Interface\AddOns\%s\…`
// where %s comes from the on-disk directory name, and Windows file
// paths are case-insensitive.
constexpr const char *kAddonPathPrefix = "Interface\\AddOns\\!!!ClassicAPI\\";
constexpr size_t kAddonPathPrefixLen = 30; // length of the literal above
constexpr const char *kAddOnsPrefix = "Interface\\AddOns\\";
char NormalizeChar(char c) {
if (c == '/') return '\\';
@@ -102,25 +113,47 @@ bool PathEqualsCI(const char *a, const char *b) {
return *a == *b;
}
// Strip the `Interface\AddOns\!!!ClassicAPI\` prefix and return the
// suffix (e.g. `Util\Print.lua`) on a match, NULL otherwise.
const char *StripAddonPrefix(const char *path) {
if (path == nullptr) return nullptr;
const char *p = path;
const char *q = kAddonPathPrefix;
while (*q) {
if (*p == '\0') return nullptr;
if (NormalizeChar(*p) != NormalizeChar(*q)) return nullptr;
++p; ++q;
// `path` with the case-insensitive `prefix` stripped, NULL when it does
// not start with it.
const char *StripPrefixCI(const char *path, const char *prefix) {
while (*prefix) {
if (*path == '\0') return nullptr;
if (NormalizeChar(*path) != NormalizeChar(*prefix)) return nullptr;
++path; ++prefix;
}
return p;
return path;
}
// The embedded bytes for `suffix`; `data` is NULL when it is not embedded.
Embed::Bytes LookupEmbedded(const char *suffix) {
for (size_t i = 0; i < ClassicAPIFiles::kFileCount; ++i) {
if (PathEqualsCI(suffix, ClassicAPIFiles::kFiles[i].path))
return Embed::Resource(ClassicAPIFiles::kFiles[i].resourceId);
int IndexOfAddon(const char *name) {
for (size_t i = 0; i < kAddonCount; ++i) {
if (PathEqualsCI(name, kAddons[i]))
return static_cast<int>(i);
}
return -1;
}
// The embedded addon `path` lies in (`Interface\AddOns\<addon>\…`), or -1.
// `*relPath` gets the manifest-relative part (`<addon>\Util\Print.lua`).
int MatchAddonPath(const char *path, const char **relPath) {
if (path == nullptr) return -1;
const char *rel = StripPrefixCI(path, kAddOnsPrefix);
if (rel == nullptr) return -1;
for (size_t i = 0; i < kAddonCount; ++i) {
const char *rest = StripPrefixCI(rel, kAddons[i]);
if (rest != nullptr && NormalizeChar(*rest) == '\\') {
*relPath = rel;
return static_cast<int>(i);
}
}
return -1;
}
// The embedded bytes for manifest path `relPath`; `data` is NULL when it
// is not embedded.
Embed::Bytes LookupEmbedded(const char *relPath) {
for (size_t i = 0; i < Manifest::kFileCount; ++i) {
if (PathEqualsCI(relPath, Manifest::kFiles[i].path))
return Embed::Resource(Manifest::kFiles[i].resourceId);
}
return {nullptr, 0};
}
@@ -187,67 +220,63 @@ int CompareVersions(const char *a, const char *b) {
return 0;
}
// Pre-extracted embedded TOC version, populated lazily on first
// FileRead_h call under our prefix. Empty until populated.
char g_embeddedVersion[64] = "";
// Which source wins the embed-vs-disk comparison, decided once on
// first relevant file read.
// Which source wins the embed-vs-disk comparison, decided per addon once
// on its first file read.
enum class Source { Undecided, Disk, Embedded };
Source g_source = Source::Undecided;
Source g_source[kAddonCount] = {};
void EnsureEmbeddedVersionExtracted() {
if (g_embeddedVersion[0] != '\0') return;
const Embed::Bytes toc = LookupEmbedded(kAddonTocFile);
if (toc.data != nullptr)
ExtractTocVersion(reinterpret_cast<const char *>(toc.data), toc.size,
g_embeddedVersion, sizeof(g_embeddedVersion));
}
// True iff the on-disk addon folder contains the `.classicapi-dev`
// True iff addon `addon`'s on-disk folder contains the `.classicapi-dev`
// marker. Asked of the ORIGINAL `FUN_FILE_EXISTS` (bypassing our embed
// hook), so it reflects a real file on disk / in an MPQ — never an
// embedded copy. That makes it a clean "is this a developer working
// tree" signal: the marker is gitignored + excluded from the embed and
// the release ships only the DLL, so a normal user's install can't
// carry it.
bool DiskHasDevMarker() {
bool DiskHasDevMarker(int addon) {
char fullPath[256];
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
kAddonPathPrefix, kDevMarkerFile);
std::snprintf(fullPath, sizeof(fullPath), "%s%s\\%s", kAddOnsPrefix,
kAddons[addon], kDevMarkerFile);
return FileExists_o(fullPath, 1) != 0;
}
// Decide whether disk or embedded should serve all reads for this
// addon. Called on the first FileRead_h call with a matching prefix.
// The `.classicapi-dev` marker forces disk unconditionally (explicit
// developer override). Otherwise reads the on-disk TOC via the original
// `FUN_FILE_READ` (bypassing our hook), parses its version, compares to
// the embedded version, and caches the newer. If disk has no TOC at
// all, embedded wins by default.
void DecideSource() {
if (g_source != Source::Undecided) return;
EnsureEmbeddedVersionExtracted();
// Decide whether disk or embedded should serve all reads for addon
// `addon`. Called on the first FileRead_h / FileExists_h call under its
// folder. The `.classicapi-dev` marker forces disk unconditionally
// (explicit developer override). Otherwise reads the on-disk TOC via the
// original `FUN_FILE_READ` (bypassing our hook), parses its version,
// compares to the embedded version, and caches the newer. If disk has no
// TOC at all, embedded wins by default.
void DecideSource(int addon) {
if (g_source[addon] != Source::Undecided) return;
// Explicit developer override: disk wins regardless of version. This
// is how a dev keeps disk precedence when running local Lua against a
// *released* DLL (where the embedded version would otherwise be newer
// and win). Dev intent lives in this marker, decoupled from the toc
// version — so users never get silently shadowed by a stray copy.
if (DiskHasDevMarker()) {
g_source = Source::Disk;
if (DiskHasDevMarker(addon)) {
g_source[addon] = Source::Disk;
return;
}
char embeddedVersion[64] = "";
char tocRelPath[256];
std::snprintf(tocRelPath, sizeof(tocRelPath), "%s\\%s.toc",
kAddons[addon], kAddons[addon]);
const Embed::Bytes toc = LookupEmbedded(tocRelPath);
if (toc.data != nullptr)
ExtractTocVersion(reinterpret_cast<const char *>(toc.data), toc.size,
embeddedVersion, sizeof(embeddedVersion));
char fullPath[256];
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
kAddonPathPrefix, kAddonTocFile);
std::snprintf(fullPath, sizeof(fullPath), "%s%s", kAddOnsPrefix,
tocRelPath);
void *diskBuf = nullptr;
size_t diskSize = 0;
const int ok = FileRead_o(0, fullPath, &diskBuf, &diskSize, 1, 1, 0);
if (ok == 0 || diskBuf == nullptr) {
g_source = Source::Embedded;
g_source[addon] = Source::Embedded;
return;
}
@@ -264,12 +293,12 @@ void DecideSource() {
// Missing or unparseable disk version → assume it's older than
// anything we ship, embedded wins.
if (diskVersion[0] == '\0') {
g_source = Source::Embedded;
g_source[addon] = Source::Embedded;
return;
}
const int cmp = CompareVersions(g_embeddedVersion, diskVersion);
g_source = (cmp > 0) ? Source::Embedded : Source::Disk;
const int cmp = CompareVersions(embeddedVersion, diskVersion);
g_source[addon] = (cmp > 0) ? Source::Embedded : Source::Disk;
}
int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
@@ -283,9 +312,10 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
return 1;
}
const char *suffix = StripAddonPrefix(path);
if (suffix == nullptr) {
// Not our embedded addon. If this is a base-TOC read for a multi-flavor
const char *relPath = nullptr;
const int addon = MatchAddonPath(path, &relPath);
if (addon < 0) {
// Not an embedded addon. If this is a base-TOC read for a multi-flavor
// addon that ships only `<Name>_Turtle.toc` / `_ClassicAPI.toc`, serve
// that in place of the missing `<Name>.toc` so it registers and loads
// (see addons/FlavorToc.h).
@@ -314,9 +344,9 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
return result;
}
DecideSource();
DecideSource(addon);
if (g_source == Source::Disk) {
if (g_source[addon] == Source::Disk) {
// Disk version is at least as new — serve disk. If disk
// doesn't have this specific file (e.g. embedded has files
// disk doesn't), fall through and try embedded.
@@ -328,7 +358,7 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
// Source::Embedded path, OR Source::Disk-but-file-missing fallback.
// Look up the embedded version and synthesize a Storm buffer the
// caller can free normally.
const Embed::Bytes entry = LookupEmbedded(suffix);
const Embed::Bytes entry = LookupEmbedded(relPath);
if (entry.data == nullptr) {
// Not in our embedded set either — let the engine try disk
// one more time (returns 0 if that also fails). Covers
@@ -357,23 +387,24 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
// would serve it. Other paths go to FlavorBindings (flavor-only addon
// Bindings.xml), then to the original.
int __stdcall FileExists_h(const char *path, int mode) {
const char *suffix = StripAddonPrefix(path);
if (suffix == nullptr) {
const char *relPath = nullptr;
const int addon = MatchAddonPath(path, &relPath);
if (addon < 0) {
if (FileExists_o(path, mode) != 0)
return 1;
return AddOns::FlavorBindings::TryExists(path, mode, FileExists_o) ? 1 : 0;
}
DecideSource();
if (g_source == Source::Disk && FileExists_o(path, mode) != 0)
DecideSource(addon);
if (g_source[addon] == Source::Disk && FileExists_o(path, mode) != 0)
return 1;
if (LookupEmbedded(suffix).data != nullptr)
if (LookupEmbedded(relPath).data != nullptr)
return 1;
return FileExists_o(path, mode);
}
// `FUN_ADDON_SCAN_FULL` — `void __cdecl()`. We pre-hook it: register
// `"!!!ClassicAPI"` into the empty registry, then let the engine's scan
// `FUN_ADDON_SCAN_FULL` — `void __cdecl()`. We pre-hook it: register the
// embedded addons into the empty registry, then let the engine's scan
// run. Dedup-safe — see file header.
using AddonScan_t = void(__cdecl *)();
AddonScan_t AddonScan_o = nullptr;
@@ -383,55 +414,55 @@ using TocParser_t = void(__fastcall *)(const char *name);
// `entry + OFF_ADDON_ENTRY_FILTER_OUT` is the "filter-out" byte. The
// flat display-array builder `FUN_0051da70` walks the linked list and
// copies an entry into `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when
// this byte is 0, so setting it hides `!!!ClassicAPI` from the
// this byte is 0, so setting it hides an embedded addon from the
// character-select AddOns list — there's no checkbox, so it can't be
// toggled off. The builder only ever *writes* this byte on the
// secure/SMSG path (`entry+0x28 != 0`); our entry is non-secure, so a
// manual `1` here is stable across rebuilds. Hiding does NOT stop
// loading (the load pass `FUN_0051f600` never reads `+0x29`) and does
// NOT break dependency resolution or by-name queries (those use the
// addon hash table, which keeps the entry regardless).
// addon hash table, which keeps the entry regardless — `LoadAddOn` of a
// hidden load-on-demand entry still works).
//
// DefaultState (`+0x2b`) needs no write: the entry constructor
// `FUN_00520550` stores 1 there, our toc has no `## DefaultState:`, and
// `FUN_00520550` stores 1 there, our tocs have no `## DefaultState:`, and
// the resolver co-hook below forces enabled anyway.
// Post-registration finalize for the embedded entry: hide it from the
// addon list. Combined with the `FUN_ADDON_ENABLE_RESOLVE` co-hook,
// `!!!ClassicAPI` always loads and never appears as a toggleable entry.
void FinalizeEmbeddedEntry() {
Addons::ForEachEntry([](uintptr_t entry) -> bool {
// Post-registration finalize for the embedded entries: hide them from the
// addon list. Combined with the `FUN_ADDON_ENABLE_RESOLVE` co-hook, an
// embedded addon is always loadable and never appears as a toggleable
// entry.
void FinalizeEmbeddedEntries() {
Addons::ForEachEntry([](uintptr_t entry) {
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
if (name == nullptr || std::strcmp(name, kAddonName) != 0)
return true; // keep walking
*reinterpret_cast<uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) = 1;
return false;
if (name != nullptr && IndexOfAddon(name) >= 0)
*reinterpret_cast<uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) = 1;
});
}
void __cdecl AddonScan_h() {
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
TocParser(kAddonName);
FinalizeEmbeddedEntry();
for (size_t i = 0; i < kAddonCount; ++i)
TocParser(kAddons[i]);
FinalizeEmbeddedEntries();
AddonScan_o();
}
// `FUN_ADDON_ENABLE_RESOLVE` — per-character enable resolver. We
// co-hook it to force-enable `!!!ClassicAPI` regardless of DefaultState
// or any stale WTF disable-override, so hiding it from the addon list
// can never leave it stuck off. Returns 2 ("enabled for all"); the
// load-pass gate only tests `!= 0`. Every other addon falls through to
// the original untouched. See the Offsets.h note for the ABI.
// co-hook it to force-enable the embedded addons regardless of
// DefaultState or any stale WTF disable-override, so hiding them from
// the addon list can never leave one stuck off. Returns 2 ("enabled for
// all"); the load-pass gate only tests `!= 0`. Every other addon falls
// through to the original untouched. See the Offsets.h note for the ABI.
using AddonEnableResolve_t =
uint32_t(__fastcall *)(const char *name, const char *account, char resolveAll);
AddonEnableResolve_t AddonEnableResolve_o = nullptr;
uint32_t __fastcall AddonEnableResolve_h(const char *name, const char *account,
char resolveAll) {
if (name != nullptr && PathEqualsCI(name, kAddonName))
if (name != nullptr && IndexOfAddon(name) >= 0)
return 2;
return AddonEnableResolve_o(name, account, resolveAll);
}
@@ -458,4 +489,8 @@ const Game::HookAutoRegister _hookEnableResolve{
} // namespace
bool IsEmbedded(const char *name) {
return name != nullptr && IndexOfAddon(name) >= 0;
}
} // namespace Addons::Embedded
+23
View File
@@ -0,0 +1,23 @@
// 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 Addons::Embedded {
// True when `name` is one of the addons embedded in the DLL (compared
// ignoring case). Their registry entries are owned by `Addons::Embedded`:
// registered first, hidden from the addon list, force-enabled.
bool IsEmbedded(const char *name);
} // namespace Addons::Embedded
+9 -54
View File
@@ -27,6 +27,7 @@
#include "Game.h"
#include "Offsets.h"
#include "addons/LoadState.h"
#include "addons/Registry.h"
#include <cstdint>
@@ -44,8 +45,7 @@ using CanLoadFn_t = uint8_t(__fastcall *)(const uint8_t *entry, char fullCheck,
const char *accountName);
using GetAccountName_t = const char *(*)();
// Hash-lookup by name → the matched record's RequiredDeps descriptor
// (`record + 0x48`), NULL on miss. Accepts an entry pointer too (its
// first bytes are the inline name). We use it only to recover the
// (`record + 0x48`), NULL on miss. We use it only to recover the
// record base; see `Script_GetAddOnOptionalDependencies`.
using ResolveReqDeps_t = uint8_t *(__fastcall *)(const char *nameOrEntry);
@@ -54,8 +54,7 @@ using ResolveReqDeps_t = uint8_t *(__fastcall *)(const char *nameOrEntry);
// a NUL-terminated string starting at the input pointer and case-
// insensitively match it against the registry, so either of these
// shapes works:
// - an AddOnEntry pointer from `GetByIndex` (the entry's first
// 12 bytes ARE the inline name buffer)
// - the name pointer `GetByIndex` returns (`[entry+0x14]`)
// - a raw `const char *` to a Lua string
//
// Mirrors `Script_GetAddOnInfo`'s own input handling exactly — it
@@ -265,11 +264,10 @@ int __fastcall Script_GetAddOnSecurity(void *L) {
// tracks from the loader itself.
//
// Resolves both numeric indices (via `FUN_ADDON_GET_BY_INDEX`) and
// string names. `IS_LOADED` accepts an entry pointer as its name arg
// because the entry's first 12 bytes are the inline NUL-terminated
// directory name (same as the other per-field accessors), so we can
// feed it either an entry or a Lua-side string transparently — and that
// same pointer is the name `LoadState` matches on.
// string names. `FUN_ADDON_GET_BY_INDEX` returns the entry's name
// pointer, so `IS_LOADED` (like the other per-field accessors) takes
// either that or a Lua-side string transparently — and that same
// pointer is the name `LoadState` matches on.
int __fastcall Script_IsAddOnLoaded(void *L) {
const uint8_t *entry = ResolveAddOnName(L);
if (entry == nullptr) {
@@ -309,49 +307,6 @@ int __fastcall Script_DoesAddOnExist(void *L) {
return 1;
}
// Resolves arg 1 to the addon's registry record, with the arg handling
// of stock `Script_GetAddOnDependencies` (`0x0048E5E0`, identical to
// 4.3.4's `Script_GetAddOnOptionalDependencies`), not `ResolveAddOnName`:
// `lua_isnumber` first (so a numeric string like `"3"` is an index), an
// out-of-range index raises the engine's own range error with the addon
// count, anything else non-string raises `usage`, and an unknown *name*
// returns nullptr with no error.
//
// Either input is then routed through the by-name hash lookup
// `FUN_ADDON_RESOLVE_REQ_DEPS` to reach the record.
// `FUN_ADDON_GET_BY_INDEX` returns a *name pointer*, NOT the record —
// reading record offsets off it lands in string memory and crashes (a
// numeric-path caller like pfUI's addon list hit exactly that). The
// resolver returns the RequiredDeps descriptor at `record+0x48`;
// subtract to recover the base.
const uint8_t *ResolveRecord(void *L, const char *usage) {
const char *name;
if (Game::Lua::IsNumber(L, 1)) {
const int idx = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
auto byIndex = reinterpret_cast<GetByIndex_t>(Offsets::FUN_ADDON_GET_BY_INDEX);
name = static_cast<const char *>(byIndex(idx));
if (name == nullptr) {
auto count = reinterpret_cast<GetAddOnCount_t>(Offsets::FUN_ADDON_GET_COUNT);
Game::Lua::Error(L, reinterpret_cast<const char *>(static_cast<uintptr_t>(
Offsets::VAR_ADDON_INDEX_RANGE_ERROR_FMT)),
count());
return nullptr;
}
} else if (Game::Lua::IsString(L, 1)) {
name = Game::Lua::ToString(L, 1);
} else {
Game::Lua::Error(L, usage);
return nullptr;
}
auto resolve = reinterpret_cast<ResolveReqDeps_t>(
Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
uint8_t *reqDesc = resolve(name);
if (reqDesc == nullptr)
return nullptr;
return reqDesc - Offsets::OFF_ADDON_REQDEPS_DESC;
}
// `C_AddOns.GetAddOnOptionalDependencies(indexOrName)` — the addon's
// `## OptionalDeps:` names as multiple return values, in declared order.
// The exact shape of the stock `GetAddOnDependencies`, but reading the
@@ -365,7 +320,7 @@ const uint8_t *ResolveRecord(void *L, const char *usage) {
// Like stock, `lua_checkstack(count)` before the pushes — a C function
// is only guaranteed LUA_MINSTACK (20) free slots.
int __fastcall Script_GetAddOnOptionalDependencies(void *L) {
const uint8_t *record = ResolveRecord(
const uint8_t *record = Addons::ResolveRecord(
L, "Usage: C_AddOns.GetAddOnOptionalDependencies(index or \"name\")");
if (record == nullptr)
return 0;
@@ -391,7 +346,7 @@ int __fastcall Script_GetAddOnOptionalDependencies(void *L) {
// client version) by `AddOns::TocRewrite` before the parser sees it.
// nil for an unknown name.
int __fastcall Script_GetAddOnInterfaceVersion(void *L) {
const uint8_t *record = ResolveRecord(
const uint8_t *record = Addons::ResolveRecord(
L, "Usage: C_AddOns.GetAddOnInterfaceVersion(index or \"name\")");
if (record == nullptr) {
Game::Lua::PushNil(L);
+199
View File
@@ -0,0 +1,199 @@
// 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/>.
// `UpdateAddOnMemoryUsage()` / `GetAddOnMemoryUsage(index or "name")` —
// 3.3.5's `Script_UpdateAddOnMemoryUsage` (0x00511510) and
// `Script_GetAddOnMemoryUsage` (0x00511530) over `Gc::Tag`.
//
// An addon's tag is its registry name pointer `[entry+0x14]`: the exact
// pointer `FUN_ADDON_LOAD_ONE` installs as the context word while the
// addon's files run (and the one 3.3.5's `GetAddOnName` returns), so the
// lookup is a pointer match like 3.3.5's `luaC_getsize`. Secure addons run
// under context 0, are never tagged, and read 0 — as in 3.3.5.
//
// 3.3.5's `LoadAddOn` also pins `lua_gctag` to the addon for its whole
// file / Bindings / SavedVariables window, so objects made there stay the
// addon's even when its code switches the context word (calling into another
// addon's function or table makes that addon's word current). The 1.12 loader
// has the same window; two 5-byte call patches open and close a `Gc::Tag` pin
// at its edges (SITE_ADDON_LOAD_WINDOW_OPEN / _CLOSE).
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "addons/Registry.h"
#include "gc/Tag.h"
#include <cstdint>
#include <cstdio>
namespace AddOns::MemoryUsage {
namespace {
constexpr uintptr_t addrExecContextDepth = Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT_DEPTH;
// The loader's own context value: the name for an insecure addon, 0 for a
// secure one (3.3.5's `addon->m_status ? addonName : nullptr`). A 0 pin means
// "no override", so a secure addon's window keeps following the context word.
void __fastcall OpenLoadWindow(const uint8_t *entry) {
const bool insecure = Game::Read<uint32_t>(entry, Offsets::OFF_ADDON_ENTRY_STATUS) != 0;
Gc::Tag::PushPin(insecure ? Game::Read<const char *>(entry, Offsets::OFF_ADDON_ENTRY_NAME_PTR)
: nullptr);
}
void CloseLoadWindow() { Gc::Tag::PopPin(); }
// Stands in for `xor eax,eax; lea ecx,[ebp-0x18]` at the window's open.
// EBX = the entry; EBP is the loader's frame.
__declspec(naked) void LoadWindowOpenThunk() {
__asm {
pushad
mov ecx, ebx
call OpenLoadWindow
popad
xor eax, eax
lea ecx, [ebp - 18h]
ret
}
}
// Stands in for `mov eax,[VAR_FRAMESCRIPT_EXEC_CONTEXT_DEPTH]` at the close.
__declspec(naked) void LoadWindowCloseThunk() {
__asm {
pushad
call CloseLoadWindow
popad
mov eax, addrExecContextDepth
mov eax, [eax]
ret
}
}
void PatchLoadWindow() {
static const uint8_t kOpen[5] = {0x33, 0xC0, 0x8D, 0x4D, 0xE8};
static const uint8_t kClose[5] = {0xA1, 0xC4, 0xEA, 0xCE, 0x00};
Common::ReplaceWithCall(Offsets::SITE_ADDON_LOAD_WINDOW_OPEN, kOpen, sizeof kOpen,
reinterpret_cast<const void *>(&LoadWindowOpenThunk));
Common::ReplaceWithCall(Offsets::SITE_ADDON_LOAD_WINDOW_CLOSE, kClose, sizeof kClose,
reinterpret_cast<const void *>(&LoadWindowCloseThunk));
}
int __fastcall Script_UpdateAddOnMemoryUsage(void * /*L*/) {
Gc::Tag::UpdateUsage();
return 0;
}
int __fastcall Script_GetAddOnMemoryUsage(void *L) {
const uint8_t *record =
Addons::ResolveRecord(L, "Usage: GetAddOnMemoryUsage(index or \"name\")");
const char *tag =
record != nullptr
? Game::Read<const char *>(record, Offsets::OFF_ADDON_ENTRY_NAME_PTR)
: nullptr;
const uint64_t bytes = tag != nullptr ? Gc::Tag::Usage(tag) : 0;
Game::Lua::PushNumber(L, static_cast<double>(bytes) * (1.0 / 1024.0));
return 1;
}
// `_classicapi_LuaHeapAudit()` → objectBytes, nblocks, taggedObjects.
// Walks every collectable object the engine can reach — the main list,
// the userdata list and the string table, the three lists `luaC_sweep`
// sweeps — summing `Gc::Tag::SizeOf`. `objectBytes` should sit just under
// `nblocks` (the engine's own byte count): the gap is what is not a GC
// object (string-table buckets, the main thread and its stack, buffers).
// A gap that grows with the heap means a size formula is wrong.
uint64_t SumList(const uint8_t *object) {
uint64_t total = 0;
while (object != nullptr) {
total += Gc::Tag::SizeOf(object);
object = Game::Read<const uint8_t *>(object, Offsets::OFF_LUA_GCHEADER_NEXT);
}
return total;
}
int __fastcall Script_LuaHeapAudit(void *L) {
const uint8_t *g = Game::Read<const uint8_t *>(L, Offsets::OFF_LUA_STATE_G);
uint64_t bytes = SumList(Game::Read<const uint8_t *>(g, Offsets::OFF_LUA_G_ROOTGC)) +
SumList(Game::Read<const uint8_t *>(g, Offsets::OFF_LUA_G_ROOTUDATA));
const auto buckets =
Game::Read<const uint8_t *const *>(g, Offsets::OFF_LUA_G_STRT_HASH);
const int size = Game::Read<int>(g, Offsets::OFF_LUA_G_STRT_SIZE);
for (int i = 0; i < size; ++i)
bytes += SumList(buckets[i]);
Game::Lua::PushNumber(L, static_cast<double>(bytes));
Game::Lua::PushNumber(L, static_cast<double>(Game::Read<uint32_t>(g, Offsets::OFF_LUA_G_NBLOCKS)));
Game::Lua::PushNumber(L, static_cast<double>(Gc::Tag::TaggedCount()));
return 3;
}
// `_classicapi_GcTagOf(value)` → the owner tag of a table / function /
// userdata / thread: the addon's name when the tag is an addon's, else the
// raw context word as "0x…" (e.g. RunScript's), nil when untagged.
int __fastcall Script_GcTagOf(void *L) {
const char *tag = Gc::Tag::Of(Game::Lua::ToPointer(L, 1));
if (tag == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
const char *name = nullptr;
Addons::ForEachEntry([&](uintptr_t entry) {
if (Game::Read<const char *>(entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR) != tag)
return true;
name = tag;
return false;
});
if (name != nullptr) {
Game::Lua::PushString(L, name);
} else {
char buf[16];
std::snprintf(buf, sizeof buf, "0x%08X", static_cast<unsigned>(reinterpret_cast<uintptr_t>(tag)));
Game::Lua::PushString(L, buf);
}
return 1;
}
const Game::Doc::Function kUpdateAddOnMemoryUsage{
"Recounts the memory each addon uses; GetAddOnMemoryUsage returns the result of the "
"last recount.",
{}, {}, "AddOnsGlobals"};
const Game::Doc::Field kGetArgs[] = {
Game::Doc::Req("name", "uiAddon", "An addon index or folder name."),
};
const Game::Doc::Field kGetRets[] = {
Game::Doc::Req("memory", "number",
"Kilobytes of Lua memory the addon's code allocated, as of the last "
"UpdateAddOnMemoryUsage."),
};
const Game::Doc::Function kGetAddOnMemoryUsage{
"Returns the Lua memory an addon uses, in kilobytes, as of the last "
"UpdateAddOnMemoryUsage.",
kGetArgs, kGetRets, "AddOnsGlobals"};
} // namespace
static void RegisterLuaFunctions() {
// Each pass runs before the load pass; the patch is idempotent.
PatchLoadWindow();
Game::Lua::RegisterGlobalFunction("UpdateAddOnMemoryUsage", &Script_UpdateAddOnMemoryUsage,
&kUpdateAddOnMemoryUsage);
Game::Lua::RegisterGlobalFunction("GetAddOnMemoryUsage", &Script_GetAddOnMemoryUsage,
&kGetAddOnMemoryUsage);
Game::Lua::RegisterGlobalFunction("_classicapi_LuaHeapAudit", &Script_LuaHeapAudit);
Game::Lua::RegisterGlobalFunction("_classicapi_GcTagOf", &Script_GcTagOf);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace AddOns::MemoryUsage
+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/>.
#include "addons/Registry.h"
#include "Game.h"
namespace Addons {
namespace {
using GetByIndex_t = void *(__fastcall *)(int idx0Based);
using GetAddOnCount_t = int(__fastcall *)();
using ResolveReqDeps_t = uint8_t *(__fastcall *)(const char *name);
} // namespace
// Either input is routed through the by-name hash lookup
// `FUN_ADDON_RESOLVE_REQ_DEPS` to reach the record.
// `FUN_ADDON_GET_BY_INDEX` returns a *name pointer*, NOT the record —
// reading record offsets off it lands in string memory and crashes (a
// numeric-path caller like pfUI's addon list hit exactly that). The
// resolver returns the RequiredDeps descriptor at `record+0x48`;
// subtract to recover the base.
const uint8_t *ResolveRecord(void *L, const char *usage) {
const char *name;
if (Game::Lua::IsNumber(L, 1)) {
const int idx = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
auto byIndex = reinterpret_cast<GetByIndex_t>(Offsets::FUN_ADDON_GET_BY_INDEX);
name = static_cast<const char *>(byIndex(idx));
if (name == nullptr) {
auto count = reinterpret_cast<GetAddOnCount_t>(Offsets::FUN_ADDON_GET_COUNT);
Game::Lua::Error(L, reinterpret_cast<const char *>(static_cast<uintptr_t>(
Offsets::VAR_ADDON_INDEX_RANGE_ERROR_FMT)),
count());
return nullptr;
}
} else if (Game::Lua::IsString(L, 1)) {
name = Game::Lua::ToString(L, 1);
} else {
Game::Lua::Error(L, usage);
return nullptr;
}
auto resolve = reinterpret_cast<ResolveReqDeps_t>(
Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
uint8_t *reqDesc = resolve(name);
if (reqDesc == nullptr)
return nullptr;
return reqDesc - Offsets::OFF_ADDON_REQDEPS_DESC;
}
} // namespace Addons
+9
View File
@@ -47,4 +47,13 @@ template <typename Fn> void ForEachEntry(Fn fn) {
}
}
// Resolves Lua arg 1 (index or name) to the addon's registry record, with
// the arg handling of stock `Script_GetAddOnDependencies` (`0x0048E5E0`) —
// the same shape 3.3.5 gives every `(index or "name")` addon global:
// `lua_isnumber` first (so a numeric string like `"3"` is an index), an
// out-of-range index raises the engine's own range error with the addon
// count, anything else non-string raises `usage`, and an unknown *name*
// returns nullptr with no error.
const uint8_t *ResolveRecord(void *L, const char *usage);
} // namespace Addons
+8 -10
View File
@@ -61,7 +61,7 @@
// bytes cleared), and the load pass hasn't — nothing holds entry state.
//
// Excluded from the evict path (everything else is fair game):
// - `!!!ClassicAPI` — embedded; `Addons::Embedded` owns its entry
// - the embedded addons — `Addons::Embedded` owns their entries
// (registered first + filter byte);
// - `## Secure:` / SMSG-managed entries — the parser cannot restore
// packet-delivered state (see OFF_ADDON_ENTRY_SECURE).
@@ -83,6 +83,7 @@
#include "Game.h"
#include "Offsets.h"
#include "addons/Embedded.h"
#include "addons/EngineIO.h"
#include "addons/Registry.h"
@@ -164,10 +165,6 @@ void RefreshLooseFileIndex() {
// ── Step 1.5: `##` metadata refresh (evict + re-register on change) ──
// The embedded addon's name — its entry is owned by `Addons::Embedded`
// (registered first + filter byte) and never refreshed here.
constexpr const char *kEmbeddedAddon = "!!!ClassicAPI";
// name → FNV-1a hash of the addon's `##` lines, as last parsed.
// Per-process on purpose: the registry persists across /reload, and a
// stale hash after a full re-login scan only causes one harmless
@@ -221,11 +218,12 @@ uint32_t HashTocMetadata(const char *buf, size_t size) {
return h;
}
// True when the entry never takes the evict path: the embedded addon
// (owned by `Addons::Embedded`) and SMSG-managed secure entries (the
// parser cannot restore packet-delivered state).
// True when the entry never takes the evict path: the embedded addons
// (owned by `Addons::Embedded` — registered first + filter byte) and
// SMSG-managed secure entries (the parser cannot restore packet-delivered
// state).
bool IsRefreshExempt(uintptr_t entry, const char *name) {
return name == nullptr || std::strcmp(name, kEmbeddedAddon) == 0 ||
return name == nullptr || Addons::Embedded::IsEmbedded(name) ||
*reinterpret_cast<const uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_SECURE) != 0;
}
@@ -373,7 +371,7 @@ void FixupReverseLoadWith(const std::vector<uintptr_t> &added) {
// `FUN_0051DA70` phase 2, mirrored (never call that function itself —
// its phase 1 consumes an SMSG_ADDON_INFO packet): rebuild the
// `GetNumAddOns`/`GetAddOnInfo(i)` name-pointer array from the linked
// list, skipping filtered entries (keeps `!!!ClassicAPI` hidden), then
// list, skipping filtered entries (keeps the embedded addons hidden), then
// sort with the engine's own comparator to restore alphabetical order.
void RebuildDisplayArray() {
auto desc = reinterpret_cast<Desc *>(
+2 -6
View File
@@ -471,14 +471,10 @@ namespace {
Group *Self(void *L) { return static_cast<Group *>(Resolve(L, 1, Group::s_class)); }
using LooseBool_t = int(__fastcall *)(void *L, int idx, int defaultValue);
bool LooseBool(void *L, int idx, int dflt) {
return reinterpret_cast<LooseBool_t>(Offsets::FUN_LUA_TO_BOOLEAN_LOOSE)(L, idx, dflt) != 0;
}
int __fastcall Script_Play(void *L) {
if (Group *g = Self(L))
g->Play(LooseBool(L, 2, 0));
g->Play(Game::Lua::ToFlag(L, 2));
return 0;
}
int __fastcall Script_Pause(void *L) {
@@ -620,7 +616,7 @@ int __fastcall Script_SetIgnoreFramerateThrottle(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
if (LooseBool(L, 2, 0))
if (Game::Lua::ToFlag(L, 2))
g->flags |= GROUP_IGNORE_THROTTLE;
else
g->flags &= ~GROUP_IGNORE_THROTTLE;
+2 -4
View File
@@ -30,8 +30,6 @@ using Lock_t = void(__fastcall *)(void *object, void *edx, const char *name);
using Unlock_t = void(__fastcall *)(void *object, void *edx, const char *name);
using BaseResolve_t = uint32_t *(__fastcall *)(void *object, void *edx, const char *name);
using Release_t = void(__stdcall *)(uint32_t *slot);
using LuaRef_t = int(__fastcall *)(void *L, int t);
using LuaUnref_t = void(__fastcall *)(void *L, int t, int ref);
using GetName_t = const char *(__fastcall *)(const void *object);
// 4.3.4's CScriptObject type name, which every class's IsA(name) accepts.
@@ -190,7 +188,7 @@ void RegisterClass(Class &cls) {
Game::Lua::PushCClosure(L, cls.methods[i].func, 0);
Game::Lua::RawSet(L, -3);
}
cls.methodsRef = reinterpret_cast<LuaRef_t>(Offsets::LUA_REF_REF)(L, Game::Lua::REGISTRY_INDEX);
cls.methodsRef = Game::Lua::Ref(L, Game::Lua::REGISTRY_INDEX);
}
// ---- The base methods -----------------------------------------------------------
@@ -306,7 +304,7 @@ int __fastcall Script_SetScript(void *L) {
}
reinterpret_cast<Release_t>(Offsets::FUN_SCRIPT_RELEASE)(slot);
Game::Lua::SetTop(L, 3);
const int ref = reinterpret_cast<LuaRef_t>(Offsets::LUA_REF_REF)(L, Game::Lua::REGISTRY_INDEX);
const int ref = Game::Lua::Ref(L, Game::Lua::REGISTRY_INDEX);
slot[0] = ref > 0 ? static_cast<uint32_t>(ref) : 0;
slot[1] = Game::Read<uint32_t>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT);
return 0;
+1 -2
View File
@@ -38,7 +38,6 @@ namespace {
using SetScriptText_t = bool(__fastcall *)(void *object, void *edx, const char *name, const char *text);
using StringToPoint_t = int(__fastcall *)(const char *name, int *point);
using LuaRef_t = int(__fastcall *)(void *L, int t);
const void *FindChild(const void *node, const char *tag) {
for (const void *c = ::Xml::FirstChild(node); c != nullptr; c = ::Xml::NextSibling(c))
@@ -103,7 +102,7 @@ void LoadScripts(Object *object, const void *node) {
continue;
}
reinterpret_cast<void(__stdcall *)(uint32_t *)>(Offsets::FUN_SCRIPT_RELEASE)(slot);
slot[0] = static_cast<uint32_t>(reinterpret_cast<LuaRef_t>(Offsets::LUA_REF_REF)(L, Game::Lua::REGISTRY_INDEX));
slot[0] = static_cast<uint32_t>(Game::Lua::Ref(L, Game::Lua::REGISTRY_INDEX));
slot[1] = 0;
continue;
}
+12 -10
View File
@@ -46,7 +46,6 @@
#include "Game.h"
#include "Offsets.h"
#include "debug/Log.h"
#include "event/Custom.h"
#include <algorithm>
@@ -98,7 +97,6 @@ struct Entry {
std::vector<Entry> g_entries;
Env g_pass = ENV_NONE;
unsigned g_sealed = 0;
int g_recordedThisPass = 0;
constexpr const char *kDebugPrefix = "_classicapi_";
@@ -131,7 +129,6 @@ void Record(Kind kind, const char *table, const char *name, const void *registry
const Function *doc) {
if (g_pass == ENV_NONE || name == nullptr)
return;
++g_recordedThisPass;
if (Entry *e = Find(kind, table, name, registry)) {
e->env |= g_pass;
if (e->doc == nullptr)
@@ -167,10 +164,16 @@ const WidgetSystem kWidgetSystems[] = {
{Offsets::VAR_MODEL_METHOD_REGISTRY, "SimpleModelAPI"},
};
// Hashes of the frame classes this DLL adds (NameWidgetSystem).
std::vector<WidgetSystem> g_ownWidgetSystems;
const char *WidgetSystemFor(const void *registry) {
for (const WidgetSystem &w : kWidgetSystems)
if (reinterpret_cast<const void *>(w.registry) == registry)
return w.system;
for (const WidgetSystem &w : g_ownWidgetSystems)
if (reinterpret_cast<const void *>(w.registry) == registry)
return w.system;
return nullptr;
}
@@ -613,19 +616,13 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
void BeginPass(Env env) {
g_pass = (g_sealed & env) != 0 ? ENV_NONE : env;
g_recordedThisPass = 0;
}
void EndPass() {
if (g_pass == ENV_NONE)
return;
g_sealed |= g_pass;
const Env done = g_pass;
g_pass = ENV_NONE;
const std::vector<std::string> missing = Undocumented();
Debug::Log::Printf("[doc] %s pass: %d registrations recorded, %d undocumented",
done == ENV_GLUE ? "glue" : "game", g_recordedThisPass,
static_cast<int>(missing.size()));
}
void RecordGlobal(const char *name, const Function *doc) {
@@ -659,11 +656,16 @@ void RecordFrameMethods(const void *registry, const Game::Lua::FrameMethodEntry
}
}
void NameWidgetSystem(const void *registry, const char *system) {
if (WidgetSystemFor(registry) != nullptr)
return;
g_ownWidgetSystems.push_back({reinterpret_cast<uintptr_t>(registry), system});
}
void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntegerEntry *entries,
int count, const char *system) {
if (g_pass == ENV_NONE || sub == nullptr)
return;
++g_recordedThisPass;
if (Entry *e = Find(KIND_ENUM, parent, sub, nullptr)) {
e->env |= g_pass;
if (e->enumSystem == nullptr)
+3
View File
@@ -34,6 +34,9 @@ void RecordGlobal(const char *name, const Game::Doc::Function *doc);
void RecordTable(const char *table, const char *name, const Game::Doc::Function *doc);
void RecordFrameMethods(const void *registry, const Game::Lua::FrameMethodEntry *table,
int count, const Game::Doc::Method *docs, int docCount);
// Names the widget system of a method hash this DLL owns (a frame class it
// adds), alongside the engine registries the exporter already knows.
void NameWidgetSystem(const void *registry, const char *system);
void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntegerEntry *entries,
int count, const char *system);
+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 "Common.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
// CanCancelAuction(index) — 4.3.4's Script_CanCancelAuction (0x00975180):
// nothing without an auctioneer, nil for an owner-list index with no row or a
// sold row, else 1. The gates are 1.12's own Script_CancelAuction (0x004D0260):
// auctioneer GUID set, ftol(index) - 1 below the owner count (unsigned, so 0 and
// negatives miss), non-null record. 1.12's owner records carry no sold flag, so
// that 4.3.4 gate has nothing to read. The cancel-fee money check stays in
// CancelAuction, as in 4.3.4.
namespace AuctionHouse::Cancel {
namespace {
int __fastcall Script_CanCancelAuction(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: CanCancelAuction(index)");
return 0;
}
if (Game::Read<uint32_t>(Offsets::VAR_AUCTION_AUCTIONEER_GUID_LO) == 0 &&
Game::Read<uint32_t>(Offsets::VAR_AUCTION_AUCTIONEER_GUID_HI) == 0)
return 0;
const uint32_t index = static_cast<uint32_t>(static_cast<int64_t>(Game::Lua::ToNumber(L, 1))) - 1;
const auto *entries = reinterpret_cast<void *const *>(Offsets::VAR_AUCTION_OWNER_ENTRIES);
if (index < Game::Read<uint32_t>(Offsets::VAR_AUCTION_OWNER_COUNT) && entries[index] != nullptr)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("index", "number", "Row in the \"owner\" auction list, from 1."),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Opt("canCancel", "number", nullptr,
"1 when the row is one of the player's auctions, else nil. Nothing when the auction house is closed."),
};
const Game::Doc::Function kCanCancelAuction{
"Returns whether CancelAuction can cancel a row of the owner auction list.", kArgs, kRets,
"AuctionHouseGlobals"};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("CanCancelAuction", &Script_CanCancelAuction, &kCanCancelAuction);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace AuctionHouse::Cancel
+286 -14
View File
@@ -40,6 +40,8 @@
#include "Game.h"
#include "Offsets.h"
#include "aura/Instance.h"
#include "spell/Lookup.h"
#include "ui/ColorData.h"
#include "unit/Identity.h"
@@ -474,6 +476,46 @@ int PushAuraBySlot(void *L, Data::Emit emit) {
return 1;
}
// C_UnitAuras.GetAuraDataByAuraInstanceID(unit, auraInstanceID [, buf])
// -> AuraData | nil
// With `buf`, the fields go into that table (cleared of any this aura lacks)
// and it is returned — the allocation-free form for UNIT_AURA_DELTA handlers
// that look up each changed ID. Only auras of a unit in view have IDs.
int __fastcall Script_GetAuraDataByAuraInstanceID(void *L) {
const char *unitToken = ArgUnit(L, 1);
if (unitToken == nullptr || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::PushNil(L);
return 1;
}
const auto id = static_cast<uint32_t>(Game::Lua::ToNumber(L, 2));
const bool fill = Game::Lua::Type(L, 3) == Game::Lua::TYPE_TABLE;
const uint8_t *unit = ResolveUnit(unitToken);
const int slot = Aura::Instance::SlotForID(unit, id);
if (slot < 0) {
Game::Lua::PushNil(L);
return 1;
}
if (fill) {
Game::Lua::PushValue(L, 3);
Data::Push(L, unit, slot, Data::Emit::Fill);
} else {
Data::Push(L, unit, slot);
}
return 1;
}
const Game::Doc::Field kByInstanceIDArgs[] = {
Game::Doc::Req("unit", "UnitToken"),
Game::Doc::Req("auraInstanceID", "number"),
Game::Doc::Opt("buf", "table", nullptr,
"A table to write the aura into and return instead of a new one."),
};
const Game::Doc::Field kByInstanceIDRets[] = {
Game::Doc::Opt("aura", "AuraData", nullptr, "Nil when no aura on the unit has that ID."),
};
const Game::Doc::Function kGetAuraDataByAuraInstanceID{
"The aura with the given instance ID on a unit.", kByInstanceIDArgs, kByInstanceIDRets};
// C_UnitAuras.GetAuraDataBySlot(unit, slot) -> AuraData | nil
int __fastcall Script_GetAuraDataBySlot(void *L) {
return PushAuraBySlot(L, Data::Emit::Table);
@@ -611,37 +653,267 @@ int __fastcall Script_GetAuraDispelTypeColor(void *L) {
return 1;
}
// CancelUnitBuff(unit, index [, filter]) — 4.3.4's Script_CancelUnitBuff.
// Only the player's own auras: its other branch exits a vehicle seat, which
// 1.12 has no counterpart for. The index counts in `FindNthSlot` order, the
// same order 4.3.4's GetSortedAuraSlot walks, so it matches the
// C_UnitAuras by-index getters for the same filter (HELPFUL by default).
// The cancel gate is the engine's: the slot's cancelable bit, or the spell's
// `SPELL_ATTR_EX_CHANNELED_1`, before the CMSG_CANCEL_AURA sender.
int __fastcall Script_CancelUnitBuff(void *L) {
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: CancelUnitBuff(\"unit\", index[, \"filter\"])");
return 0;
}
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr ||
Unit::Identity::GuidForToken(Game::Lua::ToString(L, 1)) !=
Unit::Identity::PlayerGuid())
return 0;
const ParsedFilter pf = ParseFilters(ArgOptString(L, 3));
const int slot =
Data::FindNthSlot(player, ArgInt(L, 2), RangeFilter(pf), pf.ToMatch());
if (slot < 0)
return 0;
const int spellID = static_cast<int>(Data::ReadSpellID(player, slot));
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return 0;
const bool cancelable =
(Data::SlotNibble(player, slot) & Offsets::UNIT_AURA_FLAG_CANCELABLE) != 0 ||
(Game::Read<uint32_t>(record, Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX) &
Offsets::SPELL_ATTR_EX_CHANNELED_1) != 0;
if (!cancelable)
return 0;
using CancelAuraSend_t = void(__fastcall *)(int spellID);
reinterpret_cast<CancelAuraSend_t>(
static_cast<uintptr_t>(Offsets::FUN_CANCEL_AURA_SEND))(spellID);
return 0;
}
const Game::Doc::Field kCancelUnitBuffArgs[] = {
Game::Doc::Req("unit", "string", "Must be the player."),
Game::Doc::Req("index", "number",
"Which aura of the ones `filter` selects, from 1."),
Game::Doc::Opt("filter", "string", "\"HELPFUL\"",
"Aura filter tokens, the same as `C_UnitAuras.GetAuraDataByIndex`."),
};
const Game::Doc::Function kCancelUnitBuff{
"Cancels one of your own auras by index.", kCancelUnitBuffArgs, {},
"SpellGlobals"};
const Game::Doc::Field kAuraDataFields[] = {
Game::Doc::Req("name", "string"),
Game::Doc::Req("icon", "string", "The icon's texture path."),
Game::Doc::Req("applications", "number", "The stack count."),
Game::Doc::Req("spellId", "number"),
Game::Doc::Req("dispelName", "string",
"Magic, Curse, Disease, or Poison; empty when the aura has no dispel type."),
Game::Doc::Req("isHelpful", "bool"),
Game::Doc::Req("isHarmful", "bool"),
Game::Doc::Req("duration", "number", "In seconds; 0 when the duration is not known."),
Game::Doc::Req("expirationTime", "number",
"When the aura ends, in GetTime() seconds; 0 when not known."),
Game::Doc::Opt("sourceUnit", "UnitToken", nullptr,
"Nil when the cast was not seen or the caster has no unit token."),
Game::Doc::Opt("sourceGUID", "WOWGUID", nullptr,
"The caster's GUID, a ClassicAPI extension; nil when the cast was not seen."),
Game::Doc::Opt("auraInstanceID", "number", nullptr,
"Nil for an aura that is not in the unit's aura list."),
Game::Doc::Req("charges", "number", "Always 0."),
Game::Doc::Req("maxCharges", "number", "Always 0."),
Game::Doc::Req("timeMod", "number", "Always 1."),
Game::Doc::Req("isFromPlayerOrPlayerPet", "bool",
"True when any player or a player's pet applied the aura."),
Game::Doc::Req("canApplyAura", "bool",
"True when the player or the pet knows the spell that applies the aura."),
Game::Doc::Req("isRaid", "bool", "True when the aura passes the RAID filter."),
Game::Doc::Req("isStealable", "bool",
"True when the player can remove this buff from an enemy."),
Game::Doc::Req("isBossAura", "bool", "Always false."),
Game::Doc::Req("isNameplateOnly", "bool", "Always false."),
Game::Doc::Req("nameplateShowAll", "bool", "Always false."),
Game::Doc::Req("nameplateShowPersonal", "bool", "Always false."),
Game::Doc::Req("shouldConsolidate", "bool", "Always false."),
};
const Game::Doc::Structure kAuraData{"AuraData", "UnitAuras", kAuraDataFields,
"One aura on a unit."};
// The Classic Era `UnitAura` tuple, shared by the four positional getters.
// `Data::PushPositional` is the source of the order.
const Game::Doc::Field kPositionalRets[] = {
Game::Doc::Opt("name", "string", nullptr,
"Nil, as the only return, when there is no such aura."),
Game::Doc::Opt("icon", "string"),
Game::Doc::Opt("count", "number"),
Game::Doc::Opt("dispelType", "string", nullptr,
"Empty when the aura has no dispel type."),
Game::Doc::Opt("duration", "number"),
Game::Doc::Opt("expirationTime", "number"),
Game::Doc::Opt("source", "UnitToken", nullptr, "Nil when the caster is not known."),
Game::Doc::Opt("isStealable", "bool"),
Game::Doc::Opt("nameplateShowPersonal", "bool", nullptr, "Always false."),
Game::Doc::Opt("spellId", "number"),
Game::Doc::Opt("canApplyAura", "bool"),
Game::Doc::Opt("isBossDebuff", "bool", nullptr, "Always false."),
Game::Doc::Opt("castByPlayer", "bool", nullptr,
"True when a player applied the aura; false for a pet."),
Game::Doc::Opt("nameplateShowAll", "bool", nullptr, "Always false."),
Game::Doc::Opt("timeMod", "number", nullptr, "Always 1."),
};
const Game::Doc::Field kAuraRets[] = {
Game::Doc::Opt("aura", "AuraData", nullptr, "Nil when there is no such aura."),
};
const Game::Doc::Field kByIndexArgs[] = {
Game::Doc::Req("unit", "UnitToken"),
Game::Doc::Req("index", "luaIndex"),
Game::Doc::Opt("filter", "AuraFilters", nullptr,
"Tokens such as HELPFUL, HARMFUL, PLAYER, RAID; HELPFUL when no range is given."),
};
const Game::Doc::Function kGetAuraDataByIndex{
"Returns the aura at an index among a unit's auras that match a filter.", kByIndexArgs,
kAuraRets};
const Game::Doc::Function kUnitAura{
"Returns the aura at an index as positional values, without making a table.",
kByIndexArgs, kPositionalRets};
const Game::Doc::Field kLockedIndexArgs[] = {
Game::Doc::Req("unit", "UnitToken"),
Game::Doc::Req("index", "luaIndex"),
};
const Game::Doc::Function kGetBuffDataByIndex{
"Returns the buff at an index on a unit.", kLockedIndexArgs, kAuraRets};
const Game::Doc::Function kGetDebuffDataByIndex{
"Returns the debuff at an index on a unit.", kLockedIndexArgs, kAuraRets};
const Game::Doc::Field kLockedFilterArgs[] = {
Game::Doc::Req("unit", "UnitToken"),
Game::Doc::Req("index", "luaIndex"),
Game::Doc::Opt("filter", "AuraFilters", nullptr,
"Further tokens such as PLAYER or RAID; the range is fixed."),
};
const Game::Doc::Function kUnitBuff{
"Returns the buff at an index as positional values, without making a table.",
kLockedFilterArgs, kPositionalRets};
const Game::Doc::Function kUnitDebuff{
"Returns the debuff at an index as positional values, without making a table.",
kLockedFilterArgs, kPositionalRets};
const Game::Doc::Field kSlotsArgs[] = {
Game::Doc::Req("unit", "UnitToken"),
Game::Doc::Opt("filter", "AuraFilters"),
Game::Doc::Opt("maxSlots", "number", nullptr, "Nil or 0 returns all of them."),
Game::Doc::Opt("continuationToken", "number", nullptr,
"The first return of the previous call, to get the next batch."),
Game::Doc::Opt("buf", "table", nullptr,
"A table to fill with the slot IDs instead; the call then returns the "
"token and the count. A ClassicAPI extension."),
};
const Game::Doc::Field kSlotsRets[] = {
Game::Doc::Opt("outContinuationToken", "number", nullptr,
"Nil when this batch reaches the last aura."),
Game::Doc::Vararg("number", "The slot IDs, valid only in the frame they are returned."),
};
const Game::Doc::Function kGetAuraSlots{
"Returns the slot IDs of a unit's auras that match a filter, in batches.", kSlotsArgs,
kSlotsRets};
const Game::Doc::Field kBySlotArgs[] = {
Game::Doc::Req("unit", "UnitToken"),
Game::Doc::Req("slot", "number", "A slot ID from C_UnitAuras.GetAuraSlots."),
};
const Game::Doc::Function kGetAuraDataBySlot{
"Returns the aura that a slot ID names.", kBySlotArgs, kAuraRets};
const Game::Doc::Function kUnitAuraBySlot{
"Returns the aura that a slot ID names as positional values, without making a table.",
kBySlotArgs, kPositionalRets};
const Game::Doc::Field kBySpellIDArgs[] = {
Game::Doc::Req("unit", "UnitToken"),
Game::Doc::Req("spellID", "number"),
Game::Doc::Opt("filter", "AuraFilters", nullptr, "Nil searches buffs, then debuffs."),
};
const Game::Doc::Function kGetUnitAuraBySpellID{
"Returns the first aura on a unit with a spell ID; the player's own aura comes first.",
kBySpellIDArgs, kAuraRets};
const Game::Doc::Field kPlayerBySpellIDArgs[] = {
Game::Doc::Req("spellID", "number"),
};
const Game::Doc::Function kGetPlayerAuraBySpellID{
"Returns the first aura on the player with a spell ID.", kPlayerBySpellIDArgs, kAuraRets};
const Game::Doc::Field kBySpellNameArgs[] = {
Game::Doc::Req("unit", "UnitToken"),
Game::Doc::Req("spellName", "cstring", "The localized name; case does not matter."),
Game::Doc::Opt("filter", "AuraFilters", nullptr, "Nil searches buffs, then debuffs."),
};
const Game::Doc::Function kGetAuraDataBySpellName{
"Returns the first aura on a unit with a spell name; the player's own aura comes first.",
kBySpellNameArgs, kAuraRets};
const Game::Doc::Field kUnitAurasArgs[] = {
Game::Doc::Req("unit", "UnitToken"),
Game::Doc::Opt("filter", "AuraFilters", nullptr,
"With no HELPFUL or HARMFUL token, both are returned."),
};
const Game::Doc::Field kUnitAurasRets[] = {
Game::Doc::Req("auras", "table", "An array of AuraData; empty when the unit has none."),
};
const Game::Doc::Function kGetUnitAuras{
"Returns all of a unit's auras that match a filter, buffs first.", kUnitAurasArgs,
kUnitAurasRets};
const Game::Doc::Field kDispelColorArgs[] = {
Game::Doc::Opt("dispelName", "string", nullptr,
"Magic, Curse, Disease, Poison, or Enrage; any other value gives the "
"no-type color."),
};
const Game::Doc::Field kDispelColorRets[] = {
Game::Doc::Req("color", "colorRGBA"),
};
const Game::Doc::Function kGetAuraDispelTypeColor{
"Returns the border color for an aura's dispel type.", kDispelColorArgs,
kDispelColorRets};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraDataByIndex",
&Script_GetAuraDataByIndex);
&Script_GetAuraDataByIndex, &kGetAuraDataByIndex);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetBuffDataByIndex",
&Script_GetBuffDataByIndex);
&Script_GetBuffDataByIndex, &kGetBuffDataByIndex);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetDebuffDataByIndex",
&Script_GetDebuffDataByIndex);
&Script_GetDebuffDataByIndex, &kGetDebuffDataByIndex);
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitAura",
&Script_UnitAura);
&Script_UnitAura, &kUnitAura);
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitBuff",
&Script_UnitBuff);
&Script_UnitBuff, &kUnitBuff);
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitDebuff",
&Script_UnitDebuff);
&Script_UnitDebuff, &kUnitDebuff);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraSlots",
&Script_GetAuraSlots);
&Script_GetAuraSlots, &kGetAuraSlots);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraDataBySlot",
&Script_GetAuraDataBySlot);
&Script_GetAuraDataBySlot, &kGetAuraDataBySlot);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraDataByAuraInstanceID",
&Script_GetAuraDataByAuraInstanceID,
&kGetAuraDataByAuraInstanceID);
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitAuraBySlot",
&Script_UnitAuraBySlot);
&Script_UnitAuraBySlot, &kUnitAuraBySlot);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetUnitAuraBySpellID",
&Script_GetUnitAuraBySpellID);
&Script_GetUnitAuraBySpellID, &kGetUnitAuraBySpellID);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetPlayerAuraBySpellID",
&Script_GetPlayerAuraBySpellID);
&Script_GetPlayerAuraBySpellID, &kGetPlayerAuraBySpellID);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraDataBySpellName",
&Script_GetAuraDataBySpellName);
&Script_GetAuraDataBySpellName, &kGetAuraDataBySpellName);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetUnitAuras",
&Script_GetUnitAuras);
&Script_GetUnitAuras, &kGetUnitAuras);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraDispelTypeColor",
&Script_GetAuraDispelTypeColor);
&Script_GetAuraDispelTypeColor, &kGetAuraDispelTypeColor);
Game::Lua::RegisterGlobalFunction("CancelUnitBuff", &Script_CancelUnitBuff,
&kCancelUnitBuff);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+15 -1
View File
@@ -193,9 +193,23 @@ int __fastcall Script_RegisterComboDuration(void *L) {
return 1;
}
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("spellID", "number"),
Game::Doc::Req("baseSeconds", "number",
"The duration at zero combo points; each point adds a fifth of the "
"difference to maxSeconds."),
Game::Doc::Req("maxSeconds", "number", "The duration at five combo points."),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Req("success", "bool"),
};
const Game::Doc::Function kRegisterComboDuration{
"Sets the combo point durations of a finisher whose client data is missing or wrong.",
kArgs, kRets, nullptr, true};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_UnitAuras", "RegisterComboDuration",
&Script_RegisterComboDuration);
&Script_RegisterComboDuration, &kRegisterComboDuration);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+128 -51
View File
@@ -15,10 +15,12 @@
#include "Game.h"
#include "Offsets.h"
#include "aura/Instance.h"
#include "aura/Source.h"
#include "dbc/Lookup.h"
#include "group/MemberStats.h"
#include "guid/Guid.h"
#include "net/PacketDispatch.h"
#include "object/Resolve.h"
#include "spell/CrowdControl.h"
#include "spell/IsSelfBuff.h"
@@ -69,14 +71,12 @@ const char *LocalizedSpellName(const uint8_t *spellRecord) {
// True when the spell's locale-resolved name equals `query`, ignoring case:
// Storm's SStrCmpI, the compare 4.3.4's GetSortedAuraSlot makes for UnitAura's
// name form. `int __stdcall(a, b, n)` — see `FUN_SSTR_CMP_I`.
// name form.
bool SpellNameMatches(const uint8_t *spellRecord, const char *query) {
const char *name = LocalizedSpellName(spellRecord);
if (name == nullptr || query == nullptr)
return false;
using SStrCmpI_t = int(__stdcall *)(const char *, const char *, int);
auto cmp = reinterpret_cast<SStrCmpI_t>(static_cast<uintptr_t>(Offsets::FUN_SSTR_CMP_I));
return cmp(name, query, 0x7FFFFFFF) == 0;
return Game::StrCmpI(name, query) == 0;
}
const char *SpellIconPath(const uint8_t *spellRecord) {
@@ -122,38 +122,55 @@ bool PlayerBuffUntilCancelled(const uint8_t *record) {
return Game::Read<int>(record, Offsets::OFF_PLAYER_BUFF_UNTIL_CANCELLED) != 0;
}
// Reads the absolute expiration timestamp (in seconds, GetTime-epoch)
// for a player-buff record, or 0 if it has expired / has no timing. The
// engine stores ms-since-tickcount-epoch which matches what Lua's
// `GetTime()` returns (engineMs * 0.001), so converting
// `expirationMs * 0.001` lands directly on the same timeline addons
// compare against on the Lua side. No epoch reconciliation needed.
//
// **Read as `uint32_t`**: GetTickCount overflows `INT_MAX` at ~24.86
// days of system uptime. A signed read flips negative past that
// point, which broke a `<= 0` early-bail that was supposed to catch
// only the "no timing" (0) sentinel. The engine's own arithmetic
// works on the unsigned tick because both sides of `expiration -
// currentMs` wrap together.
double PlayerBuffExpirationSeconds(const uint8_t *record) {
if (record == nullptr || PlayerBuffUntilCancelled(record))
return 0.0;
const int slotCode = Game::Read<int>(record, Offsets::OFF_PLAYER_BUFF_SLOT_CODE);
if (slotCode < 0 || slotCode >= Offsets::UNIT_AURA_TOTAL)
return 0.0;
auto *expirationTable = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_BUFF_EXPIRATION_TABLE));
const uint32_t expirationMs = expirationTable[slotCode];
if (expirationMs == 0)
return 0.0;
// An elapsed expiry reads as "no timer", as the engine's own
// GetPlayerBuffTimeLeft (0x004e4930 → FUN_004e4450) returns 0 once
// `(int)(now - expiration[slot]) >= 0`. `Time::Clock::Reached` takes the
// same signed diff, so it also absorbs the ~24.86-day GetTickCount wrap.
if (Time::Clock::Reached(expirationMs))
return 0.0;
return static_cast<double>(expirationMs) * 0.001;
// The applied duration the server last sent for each of the player's aura
// slots. SMSG_UPDATE_AURA_DURATION (`u8 slot, i32 ms`) is the only wire
// timing for the player's own auras, and the engine's `SetAuraDuration`
// (0x004E4390) keeps just `now + ms` from it — the expiration table. The ms
// is the holder's REMAINING time (tortoise-wow `UpdateAuraDuration` sends
// `GetAuraDuration()`), which on application and refresh is the true total
// (diminishing returns and the caster's modifiers already applied
// server-side), so it is kept here. Subscribers run before the engine
// handler, so an entry changes in the same packet as its slot's expiry and,
// like it, outlives the aura (the until-cancelled flag and the elapsed test
// gate both).
uint32_t g_playerDurationMs[Offsets::PLAYER_BUFF_TABLE_COUNT];
// Slots whose ms came from an aura the character already held — only what is
// left of it, not its total. Login sends those durations twice over: a fresh
// login from `_LoadAuras` while the character loads, before the player's
// create block (no player object yet); a reconnect from
// Map::ExistingPlayerLogin after it (slots full, no earlier duration).
// `PlayerAuraTiming` stands the spell's base in for the total.
bool g_playerDurationRemainingOnly[Offsets::PLAYER_BUFF_TABLE_COUNT];
void OnPacket(uint32_t opcode, Net::CDataStore *packet) {
if (opcode != Offsets::SMSG_UPDATE_AURA_DURATION)
return;
const uint8_t slot = Net::Read<uint8_t>(packet);
const int32_t ms = Net::Read<int32_t>(packet);
if (packet->m_read > packet->m_size || slot >= Offsets::PLAYER_BUFF_TABLE_COUNT)
return;
const uint8_t *player = Unit::Identity::PlayerObject();
const uint32_t previous = g_playerDurationMs[slot];
// A duration packet for an aura already in the slot is a refresh — no
// descriptor field changes, so nothing else reports it. An application's
// packet comes before the descriptor write that fills its slot (verified
// live: the slot reads 0 at that moment), so an occupied slot held the
// aura already. The login burst finds no earlier duration: the table is
// cleared with the glue screen.
const uint32_t spellId = ReadSpellID(player, slot);
g_playerDurationMs[slot] = (ms > 0) ? static_cast<uint32_t>(ms) : 0;
g_playerDurationRemainingOnly[slot] = player == nullptr || (spellId != 0 && previous == 0);
Aura::Instance::NoteUpdated(Unit::Identity::PlayerGuid(), slot);
if (spellId != 0 && previous != 0 && ms > 0)
Aura::Source::SignalRefreshed(Unit::Identity::PlayerGuid(), spellId, slot);
}
const Net::PacketDispatch::AutoSubscribe _durationSub{&OnPacket};
// The glue screen ends the table: the next login's duration burst must find
// no earlier duration, or every aura would read as refreshed.
void ResetPlayerDurations() { std::memset(g_playerDurationMs, 0, sizeof g_playerDurationMs); }
const Game::GlueModuleAutoRegister _glueReset{&ResetPlayerDurations};
// The spell's base duration in seconds, as the server computes it before
// combo points and caster modifiers: the SpellDuration row's base, with
@@ -189,7 +206,7 @@ double SpellBaseDurationSeconds(uint32_t spellID) {
}
// True if the engine-tick expiration `expMs` has already reached/passed now.
// Same signed-wrap-safe compare `PlayerBuffExpirationSeconds` uses (absorbs
// Same signed-wrap-safe compare `PlayerAuraTiming` uses (absorbs
// the ~24.86-day GetTickCount wrap). An already-elapsed cached expiration is
// not meaningful — the caster is kept regardless, but the time is reported as
// unknown rather than as a negative remaining.
@@ -879,18 +896,28 @@ static Display ResolveDisplay(uint32_t spellID, uint64_t casterGuid, bool isHelp
return d;
}
static void ClearField(void *L, const char *key) {
Game::Lua::PushString(L, key);
Game::Lua::PushNil(L);
Game::Lua::SetTable(L, -3);
}
// Writes the full modern AuraData table on top of the Lua stack from
// already-resolved values. Shared by the descriptor path (`Push`) and the
// cache-fallback path (`PushFromCache`). Net stack effect: +1 (the table).
// cache-fallback path (`PushFromCache`). Net stack effect: +1 (the table), or
// 0 with `fill`, which writes into the table already on top and clears the
// optional fields this aura doesn't have. `instanceID` 0 = none
// (`Aura::Instance` names only live descriptor auras).
static void BuildTable(void *L, uint64_t unitGuid, uint32_t spellID, int applications,
bool isHelpful, double duration, double expirationTime,
uint64_t casterGuid) {
uint64_t casterGuid, uint32_t instanceID, bool fill) {
const Display disp = ResolveDisplay(spellID, casterGuid, isHelpful, unitGuid);
const char *name = disp.name;
const char *icon = disp.icon;
const char *dispel = disp.dispelName;
Game::Lua::NewTable(L);
if (!fill)
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", name);
Game::Lua::SetFieldString(L, "icon", icon);
@@ -917,10 +944,22 @@ static void BuildTable(void *L, uint64_t unitGuid, uint32_t spellID, int applica
Guid::FormatAsString(casterGuid, guidBuf, sizeof guidBuf);
if (sourceUnit != nullptr)
Game::Lua::SetFieldString(L, "sourceUnit", sourceUnit);
else if (fill)
ClearField(L, "sourceUnit");
if (sourceGUID != nullptr)
Game::Lua::SetFieldString(L, "sourceGUID", sourceGUID);
else if (fill)
ClearField(L, "sourceGUID");
} else if (fill) {
ClearField(L, "sourceUnit");
ClearField(L, "sourceGUID");
}
if (instanceID != 0)
Game::Lua::SetFieldNumber(L, "auraInstanceID", static_cast<double>(instanceID));
else if (fill)
ClearField(L, "auraInstanceID");
Game::Lua::SetFieldNumber(L, "charges", 0);
Game::Lua::SetFieldNumber(L, "maxCharges", 0);
Game::Lua::SetFieldNumber(L, "timeMod", 1);
@@ -937,9 +976,9 @@ static void BuildTable(void *L, uint64_t unitGuid, uint32_t spellID, int applica
Game::Lua::SetFieldBool(L, "nameplateShowPersonal", false);
Game::Lua::SetFieldBool(L, "shouldConsolidate", false);
// `auraInstanceID` and `points` are deliberately omitted — modern returns
// nil for those when they don't apply, and Lua reading a missing key
// yields nil, so the table doesn't need an explicit entry.
// `points` is deliberately omitted — modern returns nil when it doesn't
// apply, and Lua reading a missing key yields nil, so the table doesn't
// need an explicit entry.
}
// Emits the Classic-Era `UnitAura` 15-value tuple onto the Lua stack from the
@@ -979,6 +1018,37 @@ static void PushPositional(void *L, uint64_t unitGuid, uint32_t spellID, int app
Game::Lua::PushNumber(L, 1); // 15 timeMod
}
bool PlayerAuraTiming(int slot, uint32_t *outExpirationMs, uint32_t *outDurationMs) {
// FindPlayerBuffRecord matches slotCode == slot, and an empty record's
// slotCode is -1, so an unbound slot must not reach it.
if (slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
return false;
const uint8_t *record = FindPlayerBuffRecord(slot);
if (record == nullptr || PlayerBuffUntilCancelled(record))
return false;
// The engine's expiry, in the GetTime() epoch (×0.001) and read as
// `uint32_t` — the tick is unsigned and wraps (see `Time::Clock`).
auto *expirationTable = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_BUFF_EXPIRATION_TABLE));
const uint32_t expirationMs = expirationTable[slot];
// An elapsed expiry reads as "no timer", as the engine's own
// GetPlayerBuffTimeLeft (0x004e4930 → FUN_004e4450) returns 0 once
// `(int)(now - expiration[slot]) >= 0` — the same signed diff `Reached` takes.
if (expirationMs == 0 || Time::Clock::Reached(expirationMs))
return false;
*outExpirationMs = expirationMs;
*outDurationMs = g_playerDurationMs[slot];
// A remaining-only ms gives way to the spell's base, unless a caster
// modifier stretched the aura past it.
if (g_playerDurationRemainingOnly[slot] && *outDurationMs != 0) {
const uint32_t spellID = ReadSpellID(Unit::Identity::PlayerObject(), slot);
const auto baseMs = static_cast<uint32_t>(SpellBaseDurationSeconds(spellID) * 1000.0);
if (baseMs > *outDurationMs)
*outDurationMs = baseMs;
}
return true;
}
// Enriches a bare `(guid, spellID)` into a full AuraData table — the shared
// core behind every push path (descriptor, cache fallback, group-array). No
// dependence on a live descriptor: the descriptor path passes what it read
@@ -989,8 +1059,9 @@ static void PushPositional(void *L, uint64_t unitGuid, uint32_t spellID, int app
// (`SpellBaseDurationSeconds`); talent/glyph
// duration modifiers are baked into the caster-side expiration, so
// `expirationTime - GetTime()` is the true remaining time even when `duration`
// omits the boost. `expirationTime` for the local player comes from the engine
// buff table (gated on `isPlayer`); for everyone else it, the caster, and the
// omits the boost. For the local player both come from `PlayerAuraTiming`
// (gated on `isPlayer`) — the server's applied duration, which no cache
// estimate overrides; for everyone else the expiration, the caster, and the
// applied (caster-modified) duration come from the `Aura::Source` SMSG_SPELL_GO
// cache when it observed the cast — a miss leaves the modern-truthful defaults
// (expiration 0, no sourceUnit/GUID). `slot` is the absolute descriptor slot
@@ -1011,18 +1082,24 @@ static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
bool isHelpful, int applications,
bool isPlayer, int slot,
const Attribution *known = nullptr,
Emit emit = Emit::Table) {
Emit emit = Emit::Table, uint32_t instanceID = 0) {
double duration = 0.0;
double expirationTime = 0.0;
uint64_t casterGuid = 0;
bool untilCancelled = false;
bool exactDuration = false;
if (spellID != 0)
duration = SpellBaseDurationSeconds(spellID);
if (isPlayer && spellID != 0) {
const uint8_t *record = FindPlayerBuffRecord(slot);
if (record != nullptr) {
untilCancelled = PlayerBuffUntilCancelled(record);
expirationTime = PlayerBuffExpirationSeconds(record);
untilCancelled = record != nullptr && PlayerBuffUntilCancelled(record);
uint32_t expMs = 0, durMs = 0;
if (PlayerAuraTiming(slot, &expMs, &durMs)) {
expirationTime = static_cast<double>(expMs) * 0.001;
if (durMs != 0) {
duration = static_cast<double>(durMs) * 0.001;
exactDuration = true;
}
}
}
if (spellID != 0 && guid != 0) {
@@ -1042,7 +1119,7 @@ static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
if (expirationTime == 0.0 && a.expirationMs != 0 &&
!ExpirationElapsed(a.expirationMs))
expirationTime = static_cast<double>(a.expirationMs) * 0.001;
if (a.durationMs != 0)
if (a.durationMs != 0 && !exactDuration)
duration = static_cast<double>(a.durationMs) * 0.001;
casterGuid = a.caster;
}
@@ -1057,7 +1134,7 @@ static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
expirationTime, casterGuid);
else
BuildTable(L, guid, spellID, applications, isHelpful, duration, expirationTime,
casterGuid);
casterGuid, instanceID, emit == Emit::Fill);
}
// Attribution held directly by an `Aura::Source` cache entry (from `Enumerate`),
@@ -1071,7 +1148,7 @@ void Push(void *L, const uint8_t *unit, int slot, Emit emit) {
const bool isHelpful = !IsSlotHarmful(unit, slot); // nibble, not slot range
PushEnriched(L, UnitGuid(unit), spellID, isHelpful, ReadStacks(unit, slot),
unit != nullptr && unit == LocalPlayer(), slot,
/*known*/ nullptr, emit);
/*known*/ nullptr, emit, Aura::Instance::IDForSlot(unit, slot));
}
namespace {
+21 -4
View File
@@ -59,8 +59,11 @@ enum class Filter { Helpful, Harmful };
// pushes the Classic-Era `UnitAura` 15-value tuple (net stack +15) with NO table
// allocation, for the `C_UnitAuras.UnitAura/UnitBuff/UnitDebuff` accessors that
// avoid the per-call GC churn. Selection + timing resolution are identical for
// both — only the terminal leaf differs.
enum class Emit { Table, Positional };
// both — only the terminal leaf differs. `Fill` writes the `AuraData` fields
// into the table already on top of the stack instead of a new one (net stack
// 0), for getters that take a caller's table to reuse; fields the aura lacks
// are cleared to nil.
enum class Emit { Table, Positional, Fill };
// Caster restriction from the `PLAYER` / `!PLAYER` aura filter tokens.
// `Any` = no restriction; `PlayerOnly` = only auras the local player cast,
@@ -104,6 +107,10 @@ struct Match {
bool raid = false;
};
// The slot's descriptor flag nibble (0 for an empty slot or a null unit).
// Bit `UNIT_AURA_FLAG_CANCELABLE` is the cancelable bit on every server.
uint8_t SlotNibble(const uint8_t *unit, int slot);
// Reads the spell ID at the given absolute slot (0..47). Returns 0
// if the slot is empty or the unit pointer is null.
uint32_t ReadSpellID(const uint8_t *unit, int slot);
@@ -173,6 +180,14 @@ int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
// whose cast we didn't observe.
bool IsPlayerCast(const uint8_t *unit, int slot);
// The server's own timing for the local player's aura in absolute `slot`:
// the engine's expiration (what `GetPlayerBuffTimeLeft` counts down) and the
// applied duration from the same SMSG_UPDATE_AURA_DURATION, both exact —
// diminishing returns and the caster's modifiers included. False when the
// slot has no running timer (empty, until-cancelled, or elapsed).
// `outDurationMs` is 0 when no duration packet was seen for the slot.
bool PlayerAuraTiming(int slot, uint32_t *outExpirationMs, uint32_t *outDurationMs);
// Applies a `CasterMode` to a per-aura "was cast by the player or a unit
// they own" answer. `Any` → always true; `PlayerOnly` → the answer;
// `NotPlayer` → its negation.
@@ -240,14 +255,16 @@ const char *DispelName(uint32_t dispelTypeID);
// dispel type a spell the player or pet knows removes from
// an enemy)
//
// And `auraInstanceID` from `Aura::Instance` (the table forms only).
//
// And these with vanilla-truthful defaults (matches modern's
// "field present but inapplicable" semantics):
// charges=0, maxCharges=0, auraInstanceID=nil, points=nil,
// charges=0, maxCharges=0, points=nil,
// isBossAura=false, isNameplateOnly=false, nameplateShowAll=false,
// nameplateShowPersonal=false, shouldConsolidate=false
//
// Net stack effect: +1 for `Emit::Table` (the table), +15 for `Emit::Positional`
// (the Classic-Era `UnitAura` tuple — see `Emit`).
// (the Classic-Era `UnitAura` tuple), 0 for `Emit::Fill` — see `Emit`.
void Push(void *L, const uint8_t *unit, int slot, Emit emit = Emit::Table);
// Fallback for the index path when the descriptor has dropped an aura's slot
+151
View File
@@ -0,0 +1,151 @@
// 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.
// UNIT_AURA_DELTA(unitTarget, updateInfo) — retail's UNIT_AURA payload under
// its own name, so the engine's UNIT_AURA stays exactly as it is.
//
// Once per frame, from the world tick, each unit whose auras changed fires
// once per unit token it has (`Unit::Identity::TokensForGUID`), the way the
// engine fires UNIT_AURA. The change set is `Aura::Instance`'s.
//
// The payload is built to cost no garbage. It carries instance IDs only —
// never AuraData; a handler reads the auras it cares about with
// `C_UnitAuras.GetAuraDataByAuraInstanceID(unit, id, buf)` — and the
// `updateInfo` table and its three ID lists are the same four tables on every
// fire, refilled each time. So they are valid only during the handler; a
// handler that keeps one must copy it.
//
// With nothing registered for the event, the tick reads one pointer and stops:
// `Aura::Instance` records no change set, and nothing here runs.
#include "Game.h"
#include "aura/Instance.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Aura::Delta {
namespace {
const Game::Doc::Field kInfoFields[] = {
Game::Doc::Req("isFullUpdate", "bool",
"True when the unit's auras must be read again in full; the lists are then nil."),
Game::Doc::Opt("addedAuraInstanceIDs", "table", nullptr,
"Instance IDs of the auras added to the unit."),
Game::Doc::Opt("updatedAuraInstanceIDs", "table", nullptr,
"Instance IDs of the unit's auras that changed in place."),
Game::Doc::Opt("removedAuraInstanceIDs", "table", nullptr,
"Instance IDs of the auras removed from the unit."),
};
const Game::Doc::Structure kInfo{
"UnitAuraDeltaInfo", "UnitAuras", kInfoFields,
"What changed in a unit's auras. Valid only while the event handler runs."};
const Game::Doc::Field kPayload[] = {
Game::Doc::Req("unitTarget", "UnitToken"),
Game::Doc::Req("updateInfo", "UnitAuraDeltaInfo"),
};
const Game::Doc::Event kDoc{
"UnitAuras",
"Fires when a unit's auras change, with the instance IDs of the auras that "
"were added, changed, or removed.",
kPayload};
const Event::Custom::AutoReserve _evt{"UNIT_AURA_DELTA", &kDoc};
// The reused payload tables, anchored in the registry of the Lua state that
// made them. LUA_NOREF until first use in each state.
constexpr int kNoRef = -2;
int g_infoRef = kNoRef;
int g_addedRef = kNoRef;
int g_updatedRef = kNoRef;
int g_removedRef = kNoRef;
// Pushes the table anchored at `ref`, making it on first use.
void PushTable(void *L, int &ref) {
if (ref == kNoRef) {
Game::Lua::NewTable(L);
ref = Game::Lua::Ref(L, Game::Lua::REGISTRY_INDEX);
}
Game::Lua::RawGetI(L, Game::Lua::REGISTRY_INDEX, ref);
}
// Sets `key` on the table at the top to the list at `ref` refilled with `ids`,
// or to nil when there are none.
void SetList(void *L, const char *key, int &ref, const uint32_t *ids, int count) {
Game::Lua::PushString(L, key);
if (count == 0) {
Game::Lua::PushNil(L);
Game::Lua::SetTable(L, -3);
return;
}
PushTable(L, ref);
for (int i = 0; i < count; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(ids[i]));
Game::Lua::RawSet(L, -3);
}
// Clear what lies past the new end: a longer earlier list, or anything a
// handler appended.
for (int i = count + 1;; ++i) {
Game::Lua::RawGetI(L, -1, i);
const bool end = Game::Lua::Type(L, -1) == Game::Lua::TYPE_NIL;
Game::Lua::SetTop(L, -2);
if (end)
break;
Game::Lua::PushNumber(L, static_cast<double>(i));
Game::Lua::PushNil(L);
Game::Lua::RawSet(L, -3);
}
Game::Lua::SetN(L, -1, count);
Game::Lua::SetTable(L, -3);
}
void Fire(const Aura::Instance::Delta &d) {
char tokens[16][32];
const int n = Unit::Identity::TokensForGUID(d.guid, tokens, 16);
void *L = Game::Lua::State();
if (n == 0 || L == nullptr)
return;
const int top = Game::Lua::GetTop(L);
for (int i = 0; i < n; ++i) {
// Refilled for each token: a handler may have changed the tables.
Game::Lua::PushString(L, tokens[i]);
PushTable(L, g_infoRef);
Game::Lua::SetFieldBool(L, "isFullUpdate", d.fullUpdate);
SetList(L, "addedAuraInstanceIDs", g_addedRef, d.added, d.addedCount);
SetList(L, "updatedAuraInstanceIDs", g_updatedRef, d.updated, d.updatedCount);
SetList(L, "removedAuraInstanceIDs", g_removedRef, d.removed, d.removedCount);
Event::Custom::FireValues(_evt.Slot(), L, top + 1, 2);
Game::Lua::SetTop(L, top);
}
}
void OnWorldTick() {
const bool listening = Event::Custom::HasListeners(_evt.Slot());
Aura::Instance::SetRecording(listening);
if (listening)
Aura::Instance::Drain(&Fire);
}
const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
// Each registration pass is a new Lua state; the old state's refs are gone.
void ResetRefs() {
g_infoRef = g_addedRef = g_updatedRef = g_removedRef = kNoRef;
}
const Game::ModuleAutoRegister _autoreg{&ResetRefs};
} // namespace
} // namespace Aura::Delta
+317
View File
@@ -0,0 +1,317 @@
// 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.
// Aura instance IDs. See `Instance.h` for the contract.
#include "Instance.h"
#include "Game.h"
#include "Offsets.h"
#include "aura/Data.h"
#include "aura/Source.h"
#include "object/Resolve.h"
#include "tick/WorldTick.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <unordered_map>
#include <vector>
namespace Aura::Instance {
namespace {
constexpr int kSlots = Offsets::UNIT_AURA_TOTAL;
constexpr int kFlagBytes = kSlots / 2; // UNIT_FIELD_AURAFLAGS: a nibble per slot
// What was last seen of one unit's aura fields, the IDs given to its visible
// auras, and (while recording) its change set since the last Drain.
struct Tracked {
const uint8_t *object = nullptr;
uint32_t spell[kSlots] = {};
uint8_t flags[kFlagBytes] = {};
uint8_t stacks[kSlots] = {};
uint32_t id[kSlots] = {}; // 0 = no visible aura in the slot
bool full = false;
std::vector<uint32_t> added, updated, removed;
};
std::unordered_map<uint64_t, Tracked> g_units;
uint32_t g_nextID = 1;
bool g_recording = false;
const uint8_t *Descriptor(const uint8_t *unit) {
return Game::Read<const uint8_t *>(unit, Offsets::OFF_CGUNIT_OBJECT_FIELDS);
}
uint8_t Nibble(const uint8_t *flags, int slot) {
return (flags[slot / 2] >> ((slot & 1) * 4)) & 0xF;
}
bool Contains(const std::vector<uint32_t> &v, uint32_t id) {
return std::find(v.begin(), v.end(), id) != v.end();
}
bool Erase(std::vector<uint32_t> &v, uint32_t id) {
const auto it = std::find(v.begin(), v.end(), id);
if (it == v.end())
return false;
v.erase(it);
return true;
}
void ClearPending(Tracked &t) {
t.full = false;
t.added.clear();
t.updated.clear();
t.removed.clear();
}
// The change-set writes, coalesced within one Drain interval: an aura added and
// removed before the consumer heard of it is never reported, and an added or
// removed aura is not also reported as updated. A pending full update already
// covers everything.
void NoteAdded(Tracked &t, uint32_t id) {
if (g_recording && !t.full)
t.added.push_back(id);
}
void NoteChanged(Tracked &t, uint32_t id) {
if (g_recording && !t.full && !Contains(t.added, id) && !Contains(t.updated, id))
t.updated.push_back(id);
}
void NoteRemoved(Tracked &t, uint32_t id) {
if (!g_recording || t.full || Erase(t.added, id))
return;
Erase(t.updated, id);
t.removed.push_back(id);
}
// Records `unit`'s aura fields as they are now, with a fresh ID for every
// visible aura. `full` marks the change set as a full update: the consumer has
// never been told about these IDs.
void Seed(Tracked &t, const uint8_t *unit, bool full) {
const uint8_t *desc = Descriptor(unit);
t.object = unit;
ClearPending(t);
t.full = full && g_recording;
if (desc == nullptr) {
std::memset(t.spell, 0, sizeof t.spell);
std::memset(t.flags, 0, sizeof t.flags);
std::memset(t.stacks, 0, sizeof t.stacks);
std::memset(t.id, 0, sizeof t.id);
return;
}
std::memcpy(t.spell, desc + Offsets::OFF_UNIT_FIELD_AURA, sizeof t.spell);
std::memcpy(t.flags, desc + Offsets::OFF_UNIT_FIELD_AURAFLAGS, sizeof t.flags);
std::memcpy(t.stacks, desc + Offsets::OFF_UNIT_FIELD_AURAAPPLICATIONS, sizeof t.stacks);
for (int slot = 0; slot < kSlots; ++slot)
t.id[slot] = Data::IsSlotPopulated(unit, slot) ? g_nextID++ : 0;
}
// Brings one slot's record in line with the live descriptor. A new spell, or a
// visibility change, ends the old instance and starts a new one; the same
// visible aura with new flags or stacks is the same instance, updated.
void SyncSlot(Tracked &t, const uint8_t *unit, const uint8_t *desc, int slot) {
const uint32_t spell = Game::Read<uint32_t>(desc, Offsets::OFF_UNIT_FIELD_AURA + slot * 4);
const uint8_t *flags = desc + Offsets::OFF_UNIT_FIELD_AURAFLAGS;
const uint8_t nibble = Nibble(flags, slot);
const uint8_t stacks = desc[Offsets::OFF_UNIT_FIELD_AURAAPPLICATIONS + slot];
if (spell == t.spell[slot] && nibble == Nibble(t.flags, slot)) {
if (stacks != t.stacks[slot]) {
t.stacks[slot] = stacks;
if (t.id[slot] != 0)
NoteChanged(t, t.id[slot]);
}
return;
}
const bool visible = Data::IsSlotPopulated(unit, slot);
if (visible && spell == t.spell[slot] && t.id[slot] != 0) {
NoteChanged(t, t.id[slot]);
} else {
if (t.id[slot] != 0)
NoteRemoved(t, t.id[slot]);
t.id[slot] = visible ? g_nextID++ : 0;
if (t.id[slot] != 0)
NoteAdded(t, t.id[slot]);
}
t.spell[slot] = spell;
// Only this slot's nibble: the byte's other half is the neighbour's, which
// its own sync records.
const uint8_t mask = static_cast<uint8_t>(0xF << ((slot & 1) * 4));
t.flags[slot / 2] = static_cast<uint8_t>((t.flags[slot / 2] & ~mask) | (flags[slot / 2] & mask));
t.stacks[slot] = stacks;
}
void SyncAll(Tracked &t, const uint8_t *unit) {
const uint8_t *desc = Descriptor(unit);
if (desc == nullptr)
return;
// The common case is no change at all: three compares, no per-slot work.
if (std::memcmp(t.spell, desc + Offsets::OFF_UNIT_FIELD_AURA, sizeof t.spell) == 0 &&
std::memcmp(t.flags, desc + Offsets::OFF_UNIT_FIELD_AURAFLAGS, sizeof t.flags) == 0 &&
std::memcmp(t.stacks, desc + Offsets::OFF_UNIT_FIELD_AURAAPPLICATIONS,
sizeof t.stacks) == 0)
return;
for (int slot = 0; slot < kSlots; ++slot)
SyncSlot(t, unit, desc, slot);
}
// The record for `unit`, created (seeded from the live fields) on first sight
// and re-seeded when its GUID now names a different object — the old one left
// view and a new one was built, so nothing recorded of it still holds. A
// rebuilt unit is always a full update (the consumer holds its old IDs). For a
// first sight, `full` says whether it is news to the consumer: true from the
// change handlers (an aura changed on a unit the consumer has no IDs for),
// false from the getters (the caller is reading the auras right now).
Tracked *Track(const uint8_t *unit, bool full, bool *seeded) {
*seeded = false;
const uint64_t guid = Unit::Identity::GuidForObject(unit);
if (guid == 0 || Descriptor(unit) == nullptr)
return nullptr;
auto [it, inserted] = g_units.try_emplace(guid);
Tracked &t = it->second;
if (inserted || t.object != unit) {
Seed(t, unit, inserted ? full : true);
*seeded = true;
}
return &t;
}
// The engine's aura handlers name the slot that changed; syncing that slot is
// what keeps a spell removed and applied again in one slot two instances (the
// two arrive in separate update blocks, so each reaches its own sync).
void OnAuraChange(const Aura::Source::Change &c) {
if (c.unit == nullptr || c.slot < 0 || c.slot >= kSlots)
return;
const auto *unit = static_cast<const uint8_t *>(c.unit);
bool seeded;
Tracked *t = Track(unit, /*full*/ true, &seeded);
if (t != nullptr && !seeded)
SyncSlot(*t, unit, Descriptor(unit), c.slot);
}
const Aura::Source::AutoObserve _obs{&OnAuraChange};
// Units that left view are forgotten; one coming back is seeded afresh with new
// IDs. While recording, `Drain` does this every frame; otherwise this sweep
// keeps the table to the units in view.
constexpr uint32_t kSweepIntervalMs = 1000;
uint32_t g_lastSweepMs = 0;
void OnWorldTick() {
const uint32_t now = Time::Clock::NowMs();
if (Time::Clock::Elapsed(g_lastSweepMs, now) < kSweepIntervalMs)
return;
g_lastSweepMs = now;
for (auto it = g_units.begin(); it != g_units.end();) {
if (Object::ByGuid(Offsets::TYPEMASK_UNIT, it->first) == nullptr)
it = g_units.erase(it);
else
++it;
}
}
const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
// A delta copied out of its record, so `Drain`'s callback can run Lua that
// grows `g_units` without invalidating it.
struct OwnedDelta {
uint64_t guid;
bool full;
std::vector<uint32_t> added, updated, removed;
};
} // namespace
uint32_t IDForSlot(const uint8_t *unit, int slot) {
if (unit == nullptr || slot < 0 || slot >= kSlots)
return 0;
bool seeded;
Tracked *t = Track(unit, /*full*/ false, &seeded);
if (t == nullptr)
return 0;
if (!seeded)
SyncSlot(*t, unit, Descriptor(unit), slot);
return t->id[slot];
}
int SlotForID(const uint8_t *unit, uint32_t id) {
if (unit == nullptr || id == 0)
return -1;
bool seeded;
Tracked *t = Track(unit, /*full*/ false, &seeded);
if (t == nullptr || seeded) // a fresh seed hands out new IDs only
return -1;
for (int slot = 0; slot < kSlots; ++slot) {
if (t->id[slot] != id)
continue;
SyncSlot(*t, unit, Descriptor(unit), slot);
return t->id[slot] == id ? slot : -1;
}
return -1;
}
void NoteUpdated(uint64_t guid, int slot) {
if (slot < 0 || slot >= kSlots)
return;
const auto it = g_units.find(guid);
if (it != g_units.end() && it->second.id[slot] != 0)
NoteChanged(it->second, it->second.id[slot]);
}
void SetRecording(bool on) {
if (on == g_recording)
return;
g_recording = on;
if (!on)
for (auto &[guid, t] : g_units)
ClearPending(t);
}
void Drain(void (*fn)(const Delta &delta)) {
std::vector<OwnedDelta> out;
for (auto it = g_units.begin(); it != g_units.end();) {
Tracked &t = it->second;
const auto *unit = static_cast<const uint8_t *>(
Object::ByGuid(Offsets::TYPEMASK_UNIT, it->first));
if (unit == nullptr) {
it = g_units.erase(it);
continue;
}
if (unit != t.object)
Seed(t, unit, /*full*/ true);
else
SyncAll(t, unit);
if (t.full || !t.added.empty() || !t.updated.empty() || !t.removed.empty()) {
out.push_back({it->first, t.full, std::move(t.added), std::move(t.updated),
std::move(t.removed)});
ClearPending(t);
}
++it;
}
for (const OwnedDelta &d : out) {
const Delta view{d.guid,
d.full,
d.added.data(),
static_cast<int>(d.added.size()),
d.updated.data(),
static_cast<int>(d.updated.size()),
d.removed.data(),
static_cast<int>(d.removed.size())};
fn(view);
}
}
} // namespace Aura::Instance
+75
View File
@@ -0,0 +1,75 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
#pragma once
#include <cstdint>
// Aura instance IDs — retail's `auraInstanceID`. 1.12 has no aura identity:
// a unit's descriptor holds 48 spell IDs and nothing that tells one application
// of a spell from the next. This module gives each aura a number when it lands
// in a descriptor slot and keeps it until the aura leaves, so the same aura
// reads the same ID from every getter, and an aura removed and applied again
// gets a new one. IDs are never reused.
//
// Only the visible auras of a unit in view get an ID: the slots
// `Aura::Data::IsSlotPopulated` accepts (the UnitBuff gate). An aura only the
// `Aura::Source` cache or the out-of-range group array knows has none.
//
// Every change goes through one step: compare what was recorded for a slot
// with the live descriptor. The engine's aura handlers (`Aura::Source`'s
// Added / Removed / Stacks changes) run it for the slot they name, which is
// what tells a removal and re-application of one spell apart; the getters run
// it before answering; and `Drain` runs it over every tracked unit, which
// catches what no handler reports (a bulk clear, an object rebuilt).
//
// While recording is on, each unit also collects the change set since the last
// `Drain` — what `UNIT_AURA_DELTA` reports.
namespace Aura::Instance {
// The instance ID of the aura in absolute descriptor `slot` on `unit`, or 0
// when the slot holds no visible aura.
uint32_t IDForSlot(const uint8_t *unit, int slot);
// The absolute descriptor slot holding instance `id` on `unit`, or -1 when no
// aura on the unit has that ID.
int SlotForID(const uint8_t *unit, uint32_t id);
// Records the aura in `slot` on `guid` as updated in place — a timing change
// with no descriptor change behind it (a refresh, a duration packet). No-op
// for a slot with no ID or a unit nobody has asked about.
void NoteUpdated(uint64_t guid, int slot);
// One unit's changes since the last `Drain`. With `fullUpdate` set the lists
// are empty and the consumer re-reads every aura (the unit was first seen, or
// its object was rebuilt).
struct Delta {
uint64_t guid;
bool fullUpdate;
const uint32_t *added;
int addedCount;
const uint32_t *updated;
int updatedCount;
const uint32_t *removed;
int removedCount;
};
// Turns change collection on or off. Off, no change set is kept, and turning
// it off discards what was collected. IDs are assigned either way.
void SetRecording(bool on);
// Reconciles every tracked unit with its descriptor, then hands each unit with
// changes to `fn` and clears them. The deltas are copied out first, so `fn`
// may run Lua that calls the getters; changes made meanwhile wait for the next
// `Drain`.
void Drain(void (*fn)(const Delta &delta));
} // namespace Aura::Instance
+113 -12
View File
@@ -18,6 +18,7 @@
#include "ComboDuration.h"
#include "Data.h"
#include "Game.h"
#include "Instance.h"
#include "Offsets.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
@@ -361,6 +362,11 @@ Entry *Claim(uint32_t now) {
// Defined in the duration-modifier section below; used by StoreFromCast's
// refresh path too.
void SignalAuraChanged(uint64_t guid);
void SignalEntryChanged(const Entry &e);
void SignalEntryRefreshed(const Entry &e);
// Defined with the aura handlers below; used by the refresh flush.
Change MakeChange(ChangeType type, void *unit, int slot, uint32_t spellId);
void NotifyObservers(const Change &change);
// Defined with the duration-modifier machinery below. A trigger can CREATE the
// aura it then edits, in which case the edit is armed at trigger time and
@@ -405,9 +411,10 @@ void StoreFromCast(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
// An in-place recast refresh changes no descriptor field, so the engine
// fires no UNIT_AURA — event-driven consumers (aura-bar addons counting
// down a once-read duration) would never re-read the new timing. Signal it
// ourselves (deferred to the world tick, coalesced). A NEW application
// doesn't need this: its descriptor write fires the engine's own event.
SignalAuraChanged(targetGuid);
// ourselves (deferred to the world tick, coalesced), and report the
// refresh. A NEW application doesn't need this: its descriptor write
// fires the engine's own event.
SignalEntryRefreshed(*e);
ConsumePendingMods(*e, now);
}
@@ -639,7 +646,65 @@ void SignalAuraChanged(uint64_t guid) {
g_pendingSignals[g_pendingSignalCount++] = guid;
}
// An entry's timing changed in place: the unit's UNIT_AURA re-broadcast, and
// the instance in the entry's slot reported as updated to UNIT_AURA_DELTA.
void SignalEntryChanged(const Entry &e) {
Aura::Instance::NoteUpdated(e.targetGuid, e.slot);
SignalAuraChanged(e.targetGuid);
}
// The refreshes of this frame (SignalRefreshed), resolved by the flush.
struct PendingRefresh {
uint64_t guid;
uint32_t spellId;
int slot;
};
constexpr int kPendingRefreshMax = 32;
PendingRefresh g_pendingRefreshes[kPendingRefreshMax];
int g_pendingRefreshCount = 0;
// A server refresh the entry mirrors — a recast, a mechanic's reset to a full
// duration: the timing signal, and a Refreshed change for the observers. Not
// for the tick-speed compression's periodic nudge (this client's estimate
// moving, no server event) nor a reduction.
void SignalEntryRefreshed(const Entry &e) {
SignalEntryChanged(e);
SignalRefreshed(e.targetGuid, e.spellId, e.slot);
}
// The slot of `unit`'s descriptor holding `spellId`, or -1.
int DescriptorSlotOf(const uint8_t *unit, uint32_t spellId) {
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
if (Aura::Data::ReadSpellID(unit, slot) == spellId)
return slot;
}
return -1;
}
// Reports the frame's refreshes whose aura the descriptor still holds, in the
// slot that was named when it still holds the spell, else wherever it is now.
void FlushRefreshes() {
constexpr int kUnitOrPlayerMask =
(1 << Offsets::OBJECT_TYPE_UNIT) | (1 << Offsets::OBJECT_TYPE_PLAYER);
const int count = g_pendingRefreshCount;
g_pendingRefreshCount = 0; // an observer's own action may queue again
for (int i = 0; i < count; ++i) {
const PendingRefresh &p = g_pendingRefreshes[i];
void *unit = Object::ByGuid(kUnitOrPlayerMask, p.guid, nullptr, 0);
if (unit == nullptr)
continue;
const auto *u = static_cast<const uint8_t *>(unit);
int slot = p.slot;
if (slot < 0 || Aura::Data::ReadSpellID(u, slot) != p.spellId)
slot = DescriptorSlotOf(u, p.spellId);
if (slot < 0)
continue;
NotifyObservers(MakeChange(ChangeType::Refreshed, unit, slot, p.spellId));
}
}
void FlushAuraSignals() {
FlushRefreshes();
// The engine broadcast plus ClassicAPI's tokens (see Unit::TokenEvents).
for (int i = 0; i < g_pendingSignalCount; ++i)
Unit::TokenEvents::Broadcast(&g_pendingSignals[i], Offsets::UNIT_EVENT_UNIT_AURA);
@@ -651,20 +716,20 @@ void ApplyModOp(Entry &e, int32_t op, int32_t valueMs, uint32_t now) {
case MOD_REFRESH:
if (e.durationMs > 0) {
e.expirationMs = now + e.durationMs; // RefreshHolder → reset to max
SignalAuraChanged(e.targetGuid);
SignalEntryRefreshed(e);
}
break;
case MOD_SET:
e.durationMs = static_cast<uint32_t>(valueMs);
e.expirationMs = now + static_cast<uint32_t>(valueMs);
SignalAuraChanged(e.targetGuid);
SignalEntryRefreshed(e);
break;
case MOD_REDUCE:
if (e.expirationMs != 0) {
if (Time::Clock::Remaining(now, e.expirationMs) >
static_cast<uint32_t>(valueMs)) {
e.expirationMs -= static_cast<uint32_t>(valueMs);
SignalAuraChanged(e.targetGuid);
SignalEntryChanged(e);
} else {
e.used = false; // shaved to/past now → server removes it
}
@@ -881,10 +946,31 @@ int __fastcall Script_RegisterAuraDurationModifierByTrigger(void *L) {
return 1;
}
const Game::Doc::Field kDurationModArgs[] = {
Game::Doc::Req("triggerFamily", "number", "The SpellFamilyName of the triggering cast."),
Game::Doc::Req("triggerSchool", "number",
"The school index the trigger must have, or below 0 for any school."),
Game::Doc::Req("affectedFamily", "number", "The SpellFamilyName of the aura to change."),
Game::Doc::Req("affectedFamilyFlags", "number",
"A SpellFamilyFlags mask the aura must overlap, or 0 to match on the icon."),
Game::Doc::Req("affectedIcon", "number",
"The SpellIconID the aura must have, or 0 for any icon."),
Game::Doc::Req("op", "string", "refresh, reduce, set, or remove."),
Game::Doc::Opt("valueSeconds", "number", nullptr, "The amount for reduce and set."),
};
const Game::Doc::Field kDurationModRets[] = {
Game::Doc::Req("success", "bool"),
};
const Game::Doc::Function kRegisterAuraDurationModifierByTrigger{
"Changes the shown duration of the caster's aura on a unit when a matching cast by "
"that caster lands.",
kDurationModArgs, kDurationModRets, nullptr, true};
void RegisterDurationModLua() {
Game::Lua::RegisterTableFunction("C_UnitAuras",
"RegisterAuraDurationModifierByTrigger",
&Script_RegisterAuraDurationModifierByTrigger);
&Script_RegisterAuraDurationModifierByTrigger,
&kRegisterAuraDurationModifierByTrigger);
}
const Game::ModuleAutoRegister _autoregDurationMod{&RegisterDurationModLua};
@@ -1011,7 +1097,7 @@ void CompressTarget(const TickCompression &c, uint64_t targetGuid,
// pollers pick it up on their own. First write nudges immediately.
if (s->lastSignalMs == 0 || now - s->lastSignalMs >= 1000) {
s->lastSignalMs = now;
SignalAuraChanged(e.targetGuid);
SignalEntryChanged(e);
}
}
}
@@ -1227,7 +1313,7 @@ int RefreshJudgementsImpl(uint64_t unitGuid, uint64_t attackerGuid, bool adopt)
e.casterGuid = attackerGuid; // adoption claim
e.expirationMs = now + e.durationMs; // RefreshHolder → own duration
e.stampMs = now;
SignalAuraChanged(e.targetGuid);
SignalEntryRefreshed(e);
++refreshed;
}
return refreshed;
@@ -1320,7 +1406,7 @@ void RefreshTriggeredExisting(uint64_t victim, const TriggeredApplication &r,
e.durationMs = newMs;
e.expirationMs = now + newMs;
e.stampMs = now;
SignalAuraChanged(victim);
SignalEntryRefreshed(e);
break; // one aura instance per (target, spell, caster) — server touches one holder
}
}
@@ -1962,7 +2048,7 @@ uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
// duration, not a five-point one.
e.expirationMs = now + e.durationMs;
e.stampMs = now;
SignalAuraChanged(e.targetGuid);
SignalEntryRefreshed(e);
return e.spellId;
}
return 0;
@@ -1983,7 +2069,7 @@ void RestampPlayerChannel(uint32_t spellId, uint32_t remainingMs) {
continue;
e.expirationMs = now + remainingMs;
e.stampMs = now;
SignalAuraChanged(e.targetGuid);
SignalEntryChanged(e);
}
}
@@ -2098,4 +2184,19 @@ AutoObserve::AutoObserve(ChangeCallback cb) : callback(cb), next(g_observers) {
g_observers = this;
}
void SignalRefreshed(uint64_t unitGuid, uint32_t spellId, int slot) {
if (unitGuid == 0 || spellId == 0)
return;
for (int i = 0; i < g_pendingRefreshCount; ++i) {
PendingRefresh &p = g_pendingRefreshes[i];
if (p.guid == unitGuid && p.spellId == spellId) {
if (p.slot < 0)
p.slot = slot;
return;
}
}
if (g_pendingRefreshCount < kPendingRefreshMax)
g_pendingRefreshes[g_pendingRefreshCount++] = {unitGuid, spellId, slot};
}
} // namespace Aura::Source
+11 -2
View File
@@ -239,8 +239,9 @@ void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray);
// An aura arriving on a unit, leaving it, or changing its stack count, as the
// engine's own handlers see it (`OnAuraAdded`, `OnAuraRemoved`,
// `OnAuraStacksChanged`), with the caster this cache binds to it.
enum class ChangeType { Added, Removed, Stacks };
// `OnAuraStacksChanged`), with the caster this cache binds to it — or its
// duration set again while it stays (Refreshed, see `SignalRefreshed`).
enum class ChangeType { Added, Removed, Stacks, Refreshed };
struct Change {
ChangeType type;
@@ -264,4 +265,12 @@ struct AutoObserve {
const AutoObserve *next;
};
// A duration set again for an aura already on `unitGuid`: the server's
// SMSG_UPDATE_AURA_DURATION for an occupied slot of the player, a recast this
// cache refreshed, a server refresh mechanic it mirrors. Observers get it as a
// Refreshed change at the world tick, once per aura per frame, and only if the
// descriptor then still holds the spell — a slot that changed hands in the
// same tick was an application, not a refresh. `slot` may be SLOT_UNBOUND.
void SignalRefreshed(uint64_t unitGuid, uint32_t spellId, int slot);
} // namespace Aura::Source
+132
View File
@@ -0,0 +1,132 @@
// 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/>.
// `securecall(func or name, ...)`, `securecallfunction(func, ...)` and
// `secureexecuterange(tbl, func, ...)` — the later clients' protected calls,
// less taint (1.12 has none). Retail, probed:
// - securecall returns the function's results; a string names a global
// function. An error goes to the error handler and the call returns
// nothing.
// - securecallfunction is securecall without the name lookup: a string is
// a value that cannot be called, so it errors and returns nothing.
// - secureexecuterange calls func(key, value, ...) for each entry of tbl in
// `next` order; an error in one goes to the error handler and the walk
// goes on. It returns nothing.
// The error handler is the one every engine script run passes
// (VAR_FRAMESCRIPT_ERROR_HANDLER_REF), as HookScript's wrapper uses.
//
// Registered on both Lua states, like the base-library helpers.
#include "Game.h"
#include "Offsets.h"
namespace BaseLib::SecureCall {
namespace {
// Pushes the engine's script error handler; false (nothing pushed) before
// the engine has set one.
bool PushErrorHandler(void *L) {
const int ref = Game::Read<int>(static_cast<uintptr_t>(Offsets::VAR_FRAMESCRIPT_ERROR_HANDLER_REF));
if (ref <= 0)
return false;
Game::Lua::RawGetI(L, Game::Lua::REGISTRY_INDEX, ref);
return true;
}
// Calls the function at stack index 1 with the values above it, under the
// error handler. Returns the result count, 0 after an error.
int CallProtected(void *L) {
const int nargs = Game::Lua::GetTop(L) - 1;
int errIdx = 0;
if (PushErrorHandler(L)) {
Game::Lua::Insert(L, 1); // [handler, func, args...]
errIdx = 1;
}
if (Game::Lua::PCall(L, nargs, Game::Lua::MULTRET, errIdx) != 0)
return 0;
return Game::Lua::GetTop(L) - errIdx;
}
int __fastcall Script_securecall(void *L) {
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING) {
Game::Lua::PushValue(L, 1);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
Game::Lua::Remove(L, 1);
Game::Lua::Insert(L, 1);
}
return CallProtected(L);
}
int __fastcall Script_securecallfunction(void *L) { return CallProtected(L); }
int __fastcall Script_secureexecuterange(void *L) {
const int n = Game::Lua::GetTop(L); // tbl, func, extra args 3..n
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE)
return 0;
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, 1) != 0) { // [.., key, value]
int errIdx = 0;
if (PushErrorHandler(L))
errIdx = n + 3;
Game::Lua::PushValue(L, 2);
Game::Lua::PushValue(L, n + 1);
Game::Lua::PushValue(L, n + 2);
for (int i = 3; i <= n; ++i)
Game::Lua::PushValue(L, i);
Game::Lua::PCall(L, n, 0, errIdx); // key, value and the n - 2 extras
Game::Lua::SetTop(L, n + 1); // the key, for the next step
}
return 0;
}
const Game::Doc::Field kCallArgs[] = {
Game::Doc::Req("func", "function", "The function to call, or for securecall the name of a global function."),
Game::Doc::Opt("...", "any", nullptr, "The arguments for the function."),
};
const Game::Doc::Field kCallRets[] = {
Game::Doc::Opt("...", "any", nullptr, "The function's results, or nothing after an error."),
};
const Game::Doc::Field kRangeArgs[] = {
Game::Doc::Req("tbl", "table", "The table whose entries are visited."),
Game::Doc::Req("func", "function", "Called as func(key, value, ...) for each entry."),
Game::Doc::Opt("...", "any", nullptr, "Extra arguments for each call."),
};
const Game::Doc::Function kSecureCall{
"Calls a function and returns its results. An error goes to the error handler and the call returns nothing.",
kCallArgs, kCallRets, "LuaGlobals"};
const Game::Doc::Function kSecureCallFunction{
"Calls a function and returns its results. An error goes to the error handler and the call returns nothing.",
kCallArgs, kCallRets, "LuaGlobals"};
const Game::Doc::Function kSecureExecuteRange{
"Calls a function for each entry of a table. An error goes to the error handler and the other entries still run.",
kRangeArgs, {}, "LuaGlobals"};
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("securecall", &Script_securecall, &kSecureCall);
Game::Lua::RegisterGlobalFunction("securecallfunction", &Script_securecallfunction, &kSecureCallFunction);
Game::Lua::RegisterGlobalFunction("secureexecuterange", &Script_secureexecuterange, &kSecureExecuteRange);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("securecall", &Script_securecall, &kSecureCall);
Game::Lua::RegisterGlueFunction("securecallfunction", &Script_securecallfunction, &kSecureCallFunction);
Game::Lua::RegisterGlueFunction("secureexecuterange", &Script_secureexecuterange, &kSecureExecuteRange);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::SecureCall
+169 -35
View File
@@ -22,6 +22,15 @@
// - `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.
// - `strlenutf8(s)` — WoW global; characters, not bytes.
// - `strcmputf8i(a, b)` — WoW global; case-insensitive UTF-8 order.
// - `strsplittable(sep, str [, pieces])`— WoW global; `strsplit` into a table.
// - `strconcat(...)` / `string.concat` — WoW global; `..` over every argument.
//
// The two UTF-8 globals are 4.3.4's `Script_strlenutf8` and the newer
// clients' `strcmputf8i`, each a thin push over a Storm helper 1.12 does not
// have (`SStrLenUTF8` / `SStrCmpUTF8I`) — `Text::Utf8` mirrors those.
// `strconcat` is 4.3.4's: `lua_concat` over the whole stack.
//
// The two pattern `string.*` ones reuse machinery 5.0 already ships:
// - `match` is `find` returning captures / the whole match instead of the
@@ -38,6 +47,7 @@
#include "Game.h"
#include "Offsets.h"
#include "text/Utf8.h"
#include <cstddef>
#include <cstring>
@@ -92,33 +102,17 @@ 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;
// The scan behind `strsplit` and `strsplittable`: splits `str` on ANY
// character in `sep` and hands each piece to `emit(start, length)` in order,
// the unsplit remainder last. `pieces > 0` caps the count, with the
// remainder (delimiters and all) as the final piece; `0` is unlimited.
// Only scans for separators while unlimited or the cap hasn't been reached;
// `pieces == 1` (or <= 0 but non-zero) yields the whole string as a single
// piece — matches 3.3.5's `strsplit` (FUN_00816a60).
template <typename Emit>
void SplitPieces(const char *sep, const char *str, int pieces, Emit emit) {
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;
@@ -131,23 +125,108 @@ int __fastcall Script_strsplit(void *L) {
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));
emit(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");
emit(segStart, static_cast<unsigned int>(std::strlen(segStart)));
}
// `strsplit(sep, str [, pieces])` — the WoW global (also aliased as
// `string.split`): the pieces as multiple values. Ported from 3.3.5 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
}
Game::Lua::PushString(L, segStart); // final piece (remainder to end)
return count + 1;
const int pieces =
Game::Lua::IsNumber(L, 3) ? static_cast<int>(Game::Lua::ToNumber(L, 3)) : 0;
int count = 0;
SplitPieces(sep, str, pieces, [L, &count](const char *start, unsigned int len) {
++count;
if (Game::Lua::CheckStack(L, count) == 0) {
Game::Lua::Error(L, "strsplit(): Stack overflow");
return; // unreachable
}
Game::Lua::PushLString(L, start, len);
});
return count;
}
// `strsplittable(sep, str [, pieces])` — the same split as one array
// table. Every piece goes to the next integer key of a fresh table, so the
// Lua stack never grows and no piece count is too many — the point of the
// table form. The args stay on the stack (GC-rooting the source, as above);
// the table sits above them at index 4.
int __fastcall Script_strsplittable(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: strsplittable(\"separators\", str [, pieces])");
return 0; // unreachable
}
const int pieces =
Game::Lua::IsNumber(L, 3) ? static_cast<int>(Game::Lua::ToNumber(L, 3)) : 0;
Game::Lua::SetTop(L, 3);
Game::Lua::NewTable(L); // 4
int count = 0;
SplitPieces(sep, str, pieces, [L, &count](const char *start, unsigned int len) {
Game::Lua::PushNumber(L, static_cast<double>(++count));
Game::Lua::PushLString(L, start, len);
Game::Lua::RawSet(L, 4);
});
return 1;
}
// `strlenutf8(s)` — 4.3.4's `Script_strlenutf8`: `SStrLenUTF8` on the
// string, the count of characters (code points) rather than bytes. Same
// usage text and `lua_isstring` gate as the original.
int __fastcall Script_strlenutf8(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: strlenutf8(string)");
return 0; // unreachable
}
Game::Lua::PushNumber(
L, static_cast<double>(Text::Utf8::Length(Game::Lua::ToString(L, 1))));
return 1;
}
// `strcmputf8i(string1, string2)` — the newer clients' global (its usage
// text is in their binaries) over `SStrCmpUTF8I`: a case-insensitive sort
// order for UTF-8 text. The number is the first folded code-point
// difference, as Storm returns it; FrameXML only ever tests the sign (`< 0`
// in sort comparators, `== 0` for a name match).
int __fastcall Script_strcmputf8i(void *L) {
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: strcmputf8i(string1, string2)");
return 0; // unreachable
}
const int result =
Text::Utf8::CompareI(Game::Lua::ToString(L, 1), Game::Lua::ToString(L, 2));
Game::Lua::PushNumber(L, static_cast<double>(result));
return 1;
}
// `strconcat(...)` / `string.concat(...)` — 4.3.4's `Script_strconcat`:
// `lua_concat` over everything on the stack, i.e. the `..` operator applied
// to every argument (numbers convert, anything else raises the concatenate
// error), `""` for no arguments, and a single argument handed back as it is.
int __fastcall Script_strconcat(void *L) {
Game::Lua::Concat(L, Game::Lua::GetTop(L));
return 1;
}
// `strjoin(delimiter, ...)` — the inverse of `strsplit`: concatenate the
@@ -384,6 +463,55 @@ int __fastcall Script_string_gsub(void *L) {
return engineGsub(L);
}
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kStrlenutf8Args[] = {
Game::Doc::Req("string", "string", "UTF-8 text."),
};
const Game::Doc::Field kStrlenutf8Rets[] = {
Game::Doc::Req("length", "number", "The number of characters; strlen counts bytes."),
};
const Game::Doc::Function kStrlenutf8{
"The number of characters in a UTF-8 string, not the number of bytes.",
kStrlenutf8Args, kStrlenutf8Rets, "LuaGlobals"};
const Game::Doc::Field kStrcmputf8iArgs[] = {
Game::Doc::Req("string1", "string"),
Game::Doc::Req("string2", "string"),
};
const Game::Doc::Field kStrcmputf8iRets[] = {
Game::Doc::Req("result", "number",
"Negative when string1 sorts first, 0 when the two match ignoring "
"case, positive when string2 sorts first."),
};
const Game::Doc::Function kStrcmputf8i{
"Compares two UTF-8 strings ignoring case, for sorting and matching.",
kStrcmputf8iArgs, kStrcmputf8iRets, "LuaGlobals"};
const Game::Doc::Field kStrsplittableArgs[] = {
Game::Doc::Req("delimiter", "string", "Each character is a delimiter."),
Game::Doc::Req("str", "string"),
Game::Doc::Opt("pieces", "number", "0",
"Maximum number of pieces; the last one holds the rest of the string. "
"0 is unlimited."),
};
const Game::Doc::Field kStrsplittableRets[] = {
Game::Doc::Req("chunks", "table", "Array of the pieces, in order."),
};
const Game::Doc::Function kStrsplittable{
"Splits a string on delimiter characters and returns the pieces as an array.",
kStrsplittableArgs, kStrsplittableRets, "LuaGlobals"};
const Game::Doc::Field kStrconcatArgs[] = {
Game::Doc::Vararg("string", "Strings or numbers."),
};
const Game::Doc::Field kStrconcatRets[] = {
Game::Doc::Req("result", "string"),
};
const Game::Doc::Function kStrconcat{
"Joins every argument into one string with nothing between them.",
kStrconcatArgs, kStrconcatRets, "LuaGlobals"};
void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
@@ -392,6 +520,7 @@ void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
Game::Lua::RegisterTableFunction("string", "trim", &Script_strtrim);
Game::Lua::RegisterTableFunction("string", "join", &Script_strjoin);
Game::Lua::RegisterTableFunction("string", "concat", &Script_strconcat, &kStrconcat);
// 4.3.4's compat.lua binds each short global and its string-table twin
// to ONE function by assignment (`gsub = str.gsub`, `strmatch =
// str.match`, `strrev = str.reverse`, `str.trim = strtrim`, `str.split =
@@ -407,7 +536,12 @@ void RegisterFns() {
Game::Lua::RegisterGlobalAlias("strjoin", "string", "join");
Game::Lua::RegisterGlobalAlias("strtrim", "string", "trim");
Game::Lua::RegisterGlobalAlias("strrev", "string", "reverse");
Game::Lua::RegisterGlobalAlias("strconcat", "string", "concat", &kStrconcat);
Game::Lua::RegisterGlobalFunction("strreplace", &Script_strreplace);
Game::Lua::RegisterGlobalFunction("strlenutf8", &Script_strlenutf8, &kStrlenutf8);
Game::Lua::RegisterGlobalFunction("strcmputf8i", &Script_strcmputf8i, &kStrcmputf8i);
Game::Lua::RegisterGlobalFunction("strsplittable", &Script_strsplittable,
&kStrsplittable);
}
// Both states are Lua 5.0 and equally missing these; RegisterTableFunction
+17 -15
View File
@@ -38,8 +38,9 @@
// command is unknown, as 4.3.4 does — and lets the engine run a saved binding
// it finds.
//
// Around whatever runs, the key-state override (Input::Modifier) hides the
// matched key's own modifiers, as 4.3.4's ExecKey does. Direct actions
// Around a direct action, the key-state override (Input::Modifier) hides the
// matched key's own modifiers, as 4.3.4's ExecKey does; a Lua command sees
// the live state, as in 1.12 (see KeyDispatch_h). Direct actions
// (Bindings::Command) run only for a key: the command co-hook acts while the
// manager's executing flag — set by the key dispatch, not by RunBinding — is
// up, which is 4.3.4's ExecKey / ExecCommand split.
@@ -52,6 +53,7 @@
#include <cstdint>
#include <cstring>
#include <list>
#include <optional>
#include <string>
#include <vector>
@@ -63,7 +65,6 @@ using KeyDispatch_t = int(__fastcall *)(void *mgr, void *edx, const char *key, i
using CommandExecute_t = int(__fastcall *)(void *mgr, void *edx, const char *cmd, int isDown);
using GetCommandAction_t = const char *(__thiscall *)(void *mgr, const char *key);
using ScriptFn_t = int(__fastcall *)(void *L);
using ToBool_t = int(__fastcall *)(void *L, int idx, int dflt);
KeyDispatch_t g_keyDispatchOriginal = nullptr;
CommandExecute_t g_commandExecuteOriginal = nullptr;
@@ -131,7 +132,6 @@ const char *SavedCommand(void *mgr, const char *key) {
// FUN_BINDING_COMMAND_EXECUTE (see OFF_BINDINGS_COMMAND_TABLE).
bool IsKnownCommand(void *mgr, const char *command) {
using HashHT_t = uint32_t(__stdcall *)(const char *);
using CmpI_t = int(__stdcall *)(const char *, const char *, int);
const uintptr_t table = reinterpret_cast<uintptr_t>(mgr) + Offsets::OFF_BINDINGS_COMMAND_TABLE;
const uint32_t mask = Game::Read<uint32_t>(table + Offsets::OFF_HASHTABLE_MASK);
if (*command == '\0' || mask == 0xFFFFFFFF)
@@ -143,9 +143,8 @@ bool IsKnownCommand(void *mgr, const char *command) {
for (uintptr_t node = Game::Read<uintptr_t>(bucket + 8); node != 0 && (node & 1) == 0;
node = Game::Read<uintptr_t>(node + link + 4)) {
if (Game::Read<uint32_t>(node) == hash &&
reinterpret_cast<CmpI_t>(Offsets::FUN_SSTR_CMP_I)(
Game::Read<const char *>(node + Offsets::OFF_HASHTABLE_NODE_NAME), command,
0x7FFFFFFF) == 0)
Game::StrCmpI(Game::Read<const char *>(node + Offsets::OFF_HASHTABLE_NODE_NAME),
command) == 0)
return true;
}
return false;
@@ -243,7 +242,13 @@ int __fastcall KeyDispatch_h(void *mgr, void *edx, const char *key, int isDown)
const KeyBinding binding = FindReducedKeyBinding(mgr, key, reduced);
if (binding.command == nullptr)
return 0;
const Input::Modifier::KeyStateOverride keyState(reduced.c_str());
// 4.3.4 hides the key's modifiers around every command. 1.12 Lua bindings
// were written against the live state: an addon that binds SHIFT-E and
// reads IsShiftKeyDown() inside its handler (Puppeteer) then sees nothing
// held. Only the direct actions, which 1.12 never had, get the override.
std::optional<Input::Modifier::KeyStateOverride> keyState;
if (Command::IsDirectAction(binding.command))
keyState.emplace(reduced.c_str());
if (binding.isOverride)
return RunOverride(mgr, binding.command, isDown);
return g_keyDispatchOriginal(mgr, edx, reduced.c_str(), isDown);
@@ -260,9 +265,6 @@ int __fastcall CommandExecute_h(void *mgr, void *edx, const char *command, int i
// --- Lua ----------------------------------------------------------------------
bool LooseBool(void *L, int idx) {
return reinterpret_cast<ToBool_t>(Offsets::FUN_LUA_TO_BOOLEAN_LOOSE)(L, idx, 0) != 0;
}
const char *OptString(void *L, int idx) {
return Game::Lua::IsString(L, idx) ? Game::Lua::ToString(L, idx) : nullptr;
@@ -321,7 +323,7 @@ int __fastcall Script_SetOverrideBinding(void *L) {
Game::Lua::Error(L, "Usage: SetOverrideBinding(owner, isPriority, \"KEY\"[, \"COMMAND\"])");
return 0;
}
SetOverrideBinding(owner, LooseBool(L, 2), Game::Lua::ToString(L, 3), OptString(L, 4));
SetOverrideBinding(owner, Game::Lua::ToFlag(L, 2), Game::Lua::ToString(L, 3), OptString(L, 4));
return 0;
}
@@ -332,7 +334,7 @@ int SetOverrideCommand(void *L, const char *prefix, const char *usage) {
return 0;
}
const std::string command = std::string(prefix) + Game::Lua::ToString(L, 4);
SetOverrideBinding(owner, LooseBool(L, 2), Game::Lua::ToString(L, 3), command.c_str());
SetOverrideBinding(owner, Game::Lua::ToFlag(L, 2), Game::Lua::ToString(L, 3), command.c_str());
return 0;
}
@@ -358,7 +360,7 @@ int __fastcall Script_SetOverrideBindingClick(void *L) {
return 0;
}
const std::string command = ClickCommand(Game::Lua::ToString(L, 4), OptString(L, 5));
SetOverrideBinding(owner, LooseBool(L, 2), Game::Lua::ToString(L, 3), command.c_str());
SetOverrideBinding(owner, Game::Lua::ToFlag(L, 2), Game::Lua::ToString(L, 3), command.c_str());
return 0;
}
@@ -376,7 +378,7 @@ int __fastcall Script_ClearOverrideBindings(void *L) {
// GetBindingAction(key [, checkOverride]): 4.3.4's GetCommandAction checks the
// override first when asked; the saved binding is the engine's own answer.
int __fastcall Script_GetBindingAction(void *L) {
if (Game::Lua::IsString(L, 1) && LooseBool(L, 2)) {
if (Game::Lua::IsString(L, 1) && Game::Lua::ToFlag(L, 2)) {
if (const char *command = ActiveOverride(Game::Lua::ToString(L, 1))) {
Game::Lua::PushString(L, command);
return 1;
+4 -20
View File
@@ -21,7 +21,8 @@
// ITEM x on release: the item named x you carry (equipment, then bags)
// used through FUN_ITEM_USE with the same target.
// MACRO x on release: macro index x when x is all digits, else the macro
// named x, run through FUN_MACRO_RUN_BODY.
// named x, run through FUN_MACRO_RUN_BODY with the button
// "LeftButton", as 4.3.4 runs it.
// CLICK f:b press and release: the frame named f gets a mouse down and a
// mouse up for button b.
//
@@ -46,13 +47,13 @@
#include "item/Arg.h"
#include "item/Location.h"
#include "item/Use.h"
#include "macro/Execute.h"
#include "spell/Placement.h"
#include "time/Clock.h"
#include "unit/Focus.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstdlib>
#include <cstring>
namespace Bindings::Command {
@@ -90,23 +91,6 @@ void UseItem(const char *name) {
Item::Use::UseItem(found.item, ModifiedTarget());
}
void RunMacro(const char *name) {
using SlotToEntry_t = const uint8_t *(__fastcall *)(unsigned slot);
using NameToSlot_t = uint32_t(__fastcall *)(const char *name);
using RunBody_t = void(__fastcall *)(const uint8_t *entry);
const char *digit = name;
while (*digit >= '0' && *digit <= '9')
++digit;
const uint32_t slot =
(*name != '\0' && *digit == '\0')
? static_cast<uint32_t>(std::atoi(name) - 1)
: reinterpret_cast<NameToSlot_t>(Offsets::FUN_MACRO_NAME_TO_SLOT)(name);
const uint8_t *entry =
reinterpret_cast<SlotToEntry_t>(Offsets::FUN_MACRO_SLOT_TO_ENTRY)(slot);
if (entry != nullptr)
reinterpret_cast<RunBody_t>(Offsets::FUN_MACRO_RUN_BODY)(entry);
}
// StringToMouseButton: the click mask for a button name, 0 for any other
// (the click vmethod then names it "UNKNOWN").
uint32_t MaskForButton(const char *button) {
@@ -231,7 +215,7 @@ int RunKey(const char *command, int isDown) {
}
if (HasPrefix(command, kMacro)) {
if (!isDown)
RunMacro(command + sizeof kMacro - 1);
Macro::Execute::Saved(command + sizeof kMacro - 1, "LeftButton");
return 1;
}
return Click(command + sizeof kClick - 1, isDown);
+5 -32
View File
@@ -15,45 +15,18 @@
#include "Game.h"
#include "Offsets.h"
#include "net/PacketWriter.h"
namespace Cache::Query {
namespace {
// The engine's stack CDataStore (see Offsets::VTABLE_CDATASTORE).
struct DataStore {
uintptr_t vtable;
void *buffer;
uint32_t base;
uint32_t alloc;
uint32_t size;
uint32_t read;
};
static_assert(sizeof(DataStore) == 0x18, "CDataStore layout");
using PutU32_t = void(__thiscall *)(DataStore *self, uint32_t value);
using PutGuid_t = void(__thiscall *)(DataStore *self, uint32_t lo, uint32_t hi);
using Send_t = void(__thiscall *)(DataStore *self);
using Free_t = void(__thiscall *)(DataStore *self, void **buffer, uint32_t *base, uint32_t *alloc);
} // namespace
// The send of FUN_DBCACHE_ITEMSTATS_GET_RECORD (0x0055BB18..0x0055BB89),
// step for step. Every DBCache<T> clone builds the same packet.
void Send(void *cache, uint32_t key) {
DataStore packet{Offsets::VTABLE_CDATASTORE, nullptr, 0, 0, 0, 0xFFFFFFFF};
const auto putU32 = reinterpret_cast<PutU32_t>(Offsets::FUN_DATASTORE_PUT_U32);
putU32(&packet, Game::Read<uint32_t>(cache, Offsets::OFF_DBCACHE_QUERY_OPCODE));
putU32(&packet, key);
Net::OutPacket packet(Game::Read<uint32_t>(cache, Offsets::OFF_DBCACHE_QUERY_OPCODE));
packet.PutU32(key);
if (Game::Read<uint8_t>(cache, Offsets::OFF_DBCACHE_OWNER_FLAG) != 0)
reinterpret_cast<PutGuid_t>(Offsets::FUN_DATASTORE_PUT_GUID)(&packet, 0, 0);
packet.read = 0;
reinterpret_cast<Send_t>(Offsets::FUN_CLIENT_SERVICES_SEND)(&packet);
if (packet.alloc != 0xFFFFFFFF) {
const auto release =
*reinterpret_cast<const Free_t *>(Offsets::VTABLE_CDATASTORE + sizeof(uintptr_t));
release(&packet, &packet.buffer, &packet.base, &packet.alloc);
}
packet.PutGuid(0);
packet.Send();
}
} // namespace Cache::Query
+67
View File
@@ -0,0 +1,67 @@
// 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/>.
// `IsChatAFK()` / `IsChatDND()` — 4.3.4's Script_IsChatAFK / Script_IsChatDND
// push CGChat::m_afk / m_dnd, the chat module's own record of the player's
// state. 1.12 keeps the AFK one (VAR_CHAT_AFK) with the same writers as 4.3.4.
// It keeps no DND one: its /dnd branch tests the player's PLAYER_FLAGS DND bit
// directly, so IsChatDND reads that bit, the value 1.12's own chat code uses.
#include "Game.h"
#include "Offsets.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Chat::AwayStatus {
namespace {
int __fastcall Script_IsChatAFK(void *L) {
Game::Lua::PushBool(L, Game::Read<uint32_t>(Offsets::VAR_CHAT_AFK) != 0);
return 1;
}
int __fastcall Script_IsChatDND(void *L) {
bool dnd = false;
if (const uint8_t *player = Unit::Identity::PlayerObject()) {
if (const auto *info = Game::Read<const uint8_t *>(player, Offsets::OFF_CGPLAYER_INFO))
dnd = (Game::Read<uint32_t>(info, Offsets::OFF_PLAYER_INFO_FLAGS) & Offsets::PLAYER_FLAG_DND) != 0;
}
Game::Lua::PushBool(L, dnd);
return 1;
}
const Game::Doc::Field kAfkRets[] = {
Game::Doc::Req("isAFK", "boolean", nullptr),
};
const Game::Doc::Function kIsChatAFK{
"Returns whether the player is marked AFK. Changes as soon as /afk is typed or AFK is cleared.",
{}, kAfkRets, "ChatGlobals"};
const Game::Doc::Field kDndRets[] = {
Game::Doc::Req("isDND", "boolean", nullptr),
};
const Game::Doc::Function kIsChatDND{
"Returns whether the player is marked DND.", {}, kDndRets, "ChatGlobals"};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsChatAFK", &Script_IsChatAFK, &kIsChatAFK);
Game::Lua::RegisterGlobalFunction("IsChatDND", &Script_IsChatDND, &kIsChatDND);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Chat::AwayStatus
+93 -2
View File
@@ -11,8 +11,9 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// CHAT_MSG_* arg11 (lineID) and arg12 (sender GUID), as 4.3.4 fires them, and
// `GetCurrentChatGUID()`, which reads the same sender from inside the handler.
// CHAT_MSG_* arg11 (lineID) and arg12 (sender GUID), as 4.3.4 fires them,
// `GetCurrentChatGUID()`, which reads the same sender from inside the handler,
// and `GetCurrentChatEvent()`, which reads the whole payload back.
//
// 4.3.4's SendChatEvent (ChatFrame.cpp, 0x008E82E0) fires every chat event
// with "%s%s%s%s%s%s%d%d%s%d%d%s%u%b": the ten arguments 1.12 already passes,
@@ -33,6 +34,13 @@
// `GetCurrentChatGUID()` returns that same sender, as nil where arg12 is "",
// so code written for the function and code reading arg12 always agree.
//
// `GetCurrentChatEvent()` (a ClassicAPI extension) returns the event name and
// the arguments exactly as fired. 1.12's ChatFrame_OnEvent reads the `event`
// and `arg1..` globals, which any handler or hook running before it may
// overwrite; the payload read here is the one this fire passed. It lives only
// for the fire, and a chat event fired from another's handler restores the
// outer payload when it returns.
//
// The sender comes from a SenderScope. Chat::Dispatch opens one around the
// chat-message dispatch with the GUID it picks for that chat type; the text
// emote builder is co-hooked below because it fires without passing its GUID
@@ -70,6 +78,33 @@ using TextEmote_t = void(__fastcall *)(const uint64_t *senderGuid, int emote, co
TextEmote_t g_textEmoteOriginal = nullptr;
// The chat event being fired, as pushed. `argc` is 12 for the stock format and
// 10 when another loader's format was fired unextended.
struct Payload {
int eventId;
const char *text, *sender, *language, *channelString, *target, *flags;
int zoneChannelID, channelNumber;
const char *channelName;
int channelUnk;
uint32_t lineID;
const char *guid;
int argc;
};
const Payload *g_payload = nullptr;
// Points g_payload at a fire for its duration, restoring the outer one after.
class PayloadScope {
public:
explicit PayloadScope(const Payload &p) : prev_(g_payload) { g_payload = &p; }
~PayloadScope() { g_payload = prev_; }
PayloadScope(const PayloadScope &) = delete;
PayloadScope &operator=(const PayloadScope &) = delete;
private:
const Payload *prev_;
};
// 1.12's chat tags are "AFK", "DND" and "GM"; 4.3.4's IsStaffChatFlag also
// matches "DEV", which this client never sends.
bool IsStaffFlag(const char *flags) { return flags != nullptr && std::strcmp(flags, "GM") == 0; }
@@ -88,7 +123,10 @@ void __cdecl FireSite(int eventId, const char *fmt, const char *text, const char
text = Chat::RaidMarkers::ForChatEvent(text, markerBuf, sizeof markerBuf);
// Another loader passing its own format: the arguments may not be ours to
// extend, so fire exactly what was asked.
Payload payload{eventId, text, sender, language, channelString, target, flags,
zoneChannelID, channelNumber, channelName, channelUnk, 0, "", 10};
if (fmt != reinterpret_cast<const char *>(Offsets::VAR_STR_CHAT_EVENT_FORMAT)) {
PayloadScope scope(payload);
fire(eventId, fmt, text, sender, language, channelString, target, flags, zoneChannelID,
channelNumber, channelName, channelUnk);
return;
@@ -97,6 +135,10 @@ void __cdecl FireSite(int eventId, const char *fmt, const char *text, const char
char guid[Guid::STRING_SIZE] = "";
if (g_sender != 0)
Guid::FormatAsString(g_sender, guid, sizeof guid);
payload.lineID = lineID;
payload.guid = guid;
payload.argc = 12;
PayloadScope scope(payload);
fire(eventId, "%s%s%s%s%s%s%d%d%s%d%d%s", text, sender, language, channelString, target, flags,
zoneChannelID, channelNumber, channelName, channelUnk, static_cast<int>(lineID), guid);
}
@@ -115,6 +157,53 @@ int __fastcall Script_GetCurrentChatGUID(void *L) {
return 1;
}
// event name, arg1..argN — the same values the handler received.
int __fastcall Script_GetCurrentChatEvent(void *L) {
using namespace Game::Lua;
const Payload *p = g_payload;
if (p == nullptr || CheckStack(L, p->argc + 1) == 0)
return 0;
const auto *entry = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR) +
p->eventId * Offsets::EVENT_ENTRY_STRIDE;
PushString(L, Game::Read<const char *>(entry, Offsets::OFF_EVENT_ENTRY_NAME));
// A null string pushes nil, as it does through the engine's dispatcher.
PushString(L, p->text);
PushString(L, p->sender);
PushString(L, p->language);
PushString(L, p->channelString);
PushString(L, p->target);
PushString(L, p->flags);
PushNumber(L, p->zoneChannelID);
PushNumber(L, p->channelNumber);
PushString(L, p->channelName);
PushNumber(L, p->channelUnk);
if (p->argc == 12) {
PushNumber(L, p->lineID);
PushString(L, p->guid);
}
return p->argc + 1;
}
const Game::Doc::Field kChatEventRets[] = {
Game::Doc::Req("event", "string", "The CHAT_MSG_* event name."),
Game::Doc::Opt("text", "string"),
Game::Doc::Opt("playerName", "string"),
Game::Doc::Opt("languageName", "string"),
Game::Doc::Opt("channelName", "string"),
Game::Doc::Opt("playerName2", "string"),
Game::Doc::Opt("specialFlags", "string"),
Game::Doc::Opt("zoneChannelID", "number"),
Game::Doc::Opt("channelIndex", "number"),
Game::Doc::Opt("channelBaseName", "string"),
Game::Doc::Opt("unused", "number"),
Game::Doc::Opt("lineID", "number"),
Game::Doc::Opt("guid", "string"),
};
const Game::Doc::Function kGetCurrentChatEvent{
"Returns the name and arguments of the CHAT_MSG_* event being handled now, or nothing "
"outside one.",
{}, kChatEventRets, "ChatGlobals", true};
// Once per process; a patched site no longer reaches FUN_FIRE_EVENT, so a
// second pass would refuse anyway.
bool g_applied = false;
@@ -139,6 +228,8 @@ void Apply() {
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetCurrentChatGUID", &Script_GetCurrentChatGUID);
Game::Lua::RegisterGlobalFunction("GetCurrentChatEvent", &Script_GetCurrentChatEvent,
&kGetCurrentChatEvent);
Apply();
}
+68
View File
@@ -0,0 +1,68 @@
// 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/>.
// `SendSystemMessage(msg)` — a local CHAT_MSG_SYSTEM line.
//
// 4.3.4 calls CGChat::AddChatMessage(msg, CHAT_MSG_SYSTEM, NULL). 1.12's
// equivalent is the chat line function FUN_CHAT_DISPATCH, called exactly as the
// game-message display FUN_00496720 calls it for a chat-kind message (0x00496822):
// chat type 10 in EDX and ten zero stack args. The engine copies the text into
// its own 0x800 buffer with a bounded SStrCopy (0x0049A9F1), so any length is
// safe. The call goes through Chat::Dispatch's hook like every engine system
// line does.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Chat::SystemMessage {
namespace {
constexpr int CHAT_MSG_SYSTEM = 0x0A;
using ChatDispatch_t = void(__fastcall *)(
const char *message, int chatType,
const char *senderName, int languageID, const char *channel, const char *targetName,
const char *flags, int speakerGuidLo, int speakerGuidHi, int rank,
int guidLo, int guidHi);
int __fastcall Script_SendSystemMessage(void *L) {
if (!Game::Lua::IsString(L, 1))
Game::Lua::Error(L, "Usage: SendSystemMessage(\"message\")");
auto dispatch = reinterpret_cast<ChatDispatch_t>(
static_cast<uintptr_t>(Offsets::FUN_CHAT_DISPATCH));
dispatch(Game::Lua::ToString(L, 1), CHAT_MSG_SYSTEM, nullptr, 0, nullptr, nullptr,
nullptr, 0, 0, 0, 0, 0);
return 0;
}
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("message", "string", "The text of the line."),
};
const Game::Doc::Function kSendSystemMessage{
"Shows a line in chat as a system message, only on your own client. It fires "
"CHAT_MSG_SYSTEM with the text as arg1.",
kArgs, {}, "ChatGlobals"};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("SendSystemMessage", &Script_SendSystemMessage,
&kSendSystemMessage);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Chat::SystemMessage
+34 -10
View File
@@ -23,12 +23,19 @@
// dead unit, and stacks only for a spell that stacks. 4.3.4 also logs a
// stack's fall (SPELL_AURA_REMOVED_DOSE), which 1.12 does not write.
//
// 4.3.4's SPELL_AURA_REFRESH needs the server to say that an aura's duration
// restarted, which 1.12 never says for another unit, so it does not occur.
// SPELL_AURA_REFRESH is 4.3.4's "same spell in the slot, duration updated"
// (CGUnit_C's aura update → UnitCombatLogAuraRefreshed). 1.12 says that only
// for the player (SMSG_UPDATE_AURA_DURATION for an occupied slot); for other
// units it is the recast Aura::Source saw, or a server refresh mechanic it
// mirrors. Both arrive as a Refreshed change, resolved at the world tick
// against the descriptor (see Aura::Source::SignalRefreshed).
//
// 1.12's dispel messages name no dispelling spell (handlers 0x005E8B60,
// 0x00628C20; vmangos writes none), so the entry's spell is spellId 0 with no
// name, as a damage shield's is:
// 0x00628C20; vmangos writes none). The server sends them from the dispel
// effect's handler, after the cast's SMSG_SPELL_GO in the same tick, so the
// spell is the caster's newest cast at the target with a dispel effect
// (RecentCasts). A dispel from an aura (Abolish Poison) has no cast, and the
// entry's spell is spellId 0 with no name, as a damage shield's is:
// 0x27b SPELLDISPELLOG packed target, packed caster, count, count × spell
// 0x262 DISPEL_FAILED caster, target (u64), spells to the end
// A dispel's BUFF or DEBUFF is the slot the aura holds on the target, which
@@ -40,6 +47,7 @@
#include "Offsets.h"
#include "aura/Data.h"
#include "aura/Source.h"
#include "combatlog/RecentCasts.h"
#include "net/PacketDispatch.h"
#include "object/Resolve.h"
#include "unit/Identity.h"
@@ -57,6 +65,10 @@ using Net::ReadPackedGuid;
constexpr uint32_t SMSG_DISPEL_FAILED = 0x262;
constexpr uint32_t SMSG_SPELLDISPELLOG = 0x27b;
// The effects a dispelling cast has.
constexpr int SPELL_EFFECT_DISPEL = 38;
constexpr int SPELL_EFFECT_DISPEL_MECHANIC = 108;
bool Overrun(const CDataStore *p) { return p->m_read > p->m_size; }
int Health(const void *unit) {
@@ -83,11 +95,20 @@ void OnAuraChange(const Aura::Source::Change &c) {
Queue(e);
return;
}
const bool added = c.type == Aura::Source::ChangeType::Added;
if ((!added && Health(c.unit) <= 0) ||
(Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_ATTRIBUTES) &
if ((Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_ATTRIBUTES) &
Offsets::SPELL_ATTR_PASSIVE) != 0)
return;
if (c.type == Aura::Source::ChangeType::Refreshed) {
// UnitCombatLogAuraRefreshed: an application's tests, no health test.
Entry e = Begin(c.caster, unit, SPELL_AURA_REFRESH, spell);
e.info |= INFO_AURA_TYPE;
e.harmful = c.harmful;
Queue(e);
return;
}
const bool added = c.type == Aura::Source::ChangeType::Added;
if (!added && Health(c.unit) <= 0)
return;
Entry e = Begin(c.caster, unit, added ? SPELL_AURA_APPLIED : SPELL_AURA_REMOVED, spell);
e.info |= INFO_AURA_TYPE;
e.harmful = c.harmful;
@@ -107,12 +128,15 @@ bool Harmful(uint64_t target, uint32_t spellId) {
return slot >= 0 && Aura::Data::IsSlotHarmful(unit, slot);
}
// One entry with the dispelled spell as its extra spell.
// One entry with the dispelled spell as its extra spell, and the cast that
// dispelled it as its spell when one was seen.
void SendDispel(uint64_t caster, uint64_t target, int event, int dispelledId, bool auraType) {
if (Hidden(SpellRecord(dispelledId)))
return;
Entry e = Begin(caster, target, event, nullptr);
e.spellPrefix = true; // the dispelling spell, which 1.12 does not name
const uint8_t *spell = RecentCasts::SpellByEffect(caster, target, SPELL_EFFECT_DISPEL,
SPELL_EFFECT_DISPEL_MECHANIC);
Entry e = Begin(caster, target, event, Hidden(spell) ? nullptr : spell);
e.spellPrefix = true; // with no cast seen, the spell 1.12 does not name
e.info |= INFO_EXTRA_SPELL;
e.v[0] = dispelledId;
if (auraType) {
+21 -14
View File
@@ -39,8 +39,10 @@
#include "Game.h"
#include "Offsets.h"
#include "combatlog/RecentCasts.h"
#include "net/PacketDispatch.h"
#include "object/Resolve.h"
#include "spell/Lookup.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -53,13 +55,9 @@ using Net::CDataStore;
using Net::Read;
using Net::ReadPackedGuid;
constexpr uint32_t SMSG_SPELL_START = 0x131;
constexpr uint32_t SMSG_SPELL_GO = 0x132;
// The cast flag that keeps a cast out of the combat log (both servers'
// CAST_FLAG_HIDDEN_COMBATLOG); 4.3.4 tests the same bit.
constexpr uint16_t CAST_FLAG_HIDDEN_COMBATLOG = 0x1;
constexpr uint16_t TARGET_FLAG_OBJECT = 0x8A02;
constexpr uint8_t SPELL_MISS_REFLECT = 11;
// 4.3.4's SpellHasOpenLockEffect tests 33 alone; 1.12's success line also
@@ -84,7 +82,7 @@ int CastingTime(void *unit, int spellId) {
// The rules both cast builders share.
bool Logged(const uint8_t *spell) {
return !Hidden(spell) && !HasEffect(spell, SPELL_EFFECT_LEARN_SPELL) &&
return !Hidden(spell) && !Spell::Lookup::HasEffect(spell, SPELL_EFFECT_LEARN_SPELL) &&
(Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX4) &
ATTR_EX4_NO_CAST_LOG) == 0;
}
@@ -105,15 +103,20 @@ void SpellStart(CDataStore *p) {
Queue(e);
}
// SMSG_SPELL_GO → UnitCombatLogCastGo: an instant cast, at its target.
// SMSG_SPELL_GO → UnitCombatLogCastGo: an instant cast, at its target. Every
// cast's hit list also goes to RecentCasts, for the messages that follow it.
void SpellGo(CDataStore *p) {
ReadPackedGuid(p);
const uint64_t caster = ReadPackedGuid(p);
const int spellId = Read<int32_t>(p);
const uint16_t flags = Read<uint16_t>(p);
const int hits = Read<uint8_t>(p);
for (int i = 0; i < hits && !Overrun(p); ++i)
Read<uint64_t>(p);
uint64_t hitList[RecentCasts::MAX_HITS];
for (int i = 0; i < hits && !Overrun(p); ++i) {
const uint64_t hit = Read<uint64_t>(p);
if (i < RecentCasts::MAX_HITS)
hitList[i] = hit;
}
const int misses = Read<uint8_t>(p);
for (int i = 0; i < misses && !Overrun(p); ++i) {
Read<uint64_t>(p);
@@ -121,22 +124,26 @@ void SpellGo(CDataStore *p) {
Read<uint8_t>(p);
}
const uint16_t targetMask = Read<uint16_t>(p);
const uint64_t target = (targetMask & TARGET_FLAG_OBJECT) ? ReadPackedGuid(p) : 0;
if (Overrun(p) || (flags & CAST_FLAG_HIDDEN_COMBATLOG) != 0)
const uint64_t target =
(targetMask & Offsets::SPELL_TARGET_FLAGS_OBJECT) ? ReadPackedGuid(p) : 0;
if (Overrun(p))
return;
RecentCasts::Record(caster, static_cast<uint32_t>(spellId), hitList, hits);
if ((flags & CAST_FLAG_HIDDEN_COMBATLOG) != 0)
return;
void *unit = Object::ByGuid(Offsets::TYPEMASK_UNIT, caster);
const uint8_t *spell = SpellRecord(spellId);
if (unit == nullptr || !Logged(spell) || HasEffect(spell, SPELL_EFFECT_OPEN_LOCK) ||
CastingTime(unit, spellId) != 0)
if (unit == nullptr || !Logged(spell) ||
Spell::Lookup::HasEffect(spell, SPELL_EFFECT_OPEN_LOCK) || CastingTime(unit, spellId) != 0)
return;
Entry e = Begin(caster, target, SPELL_CAST_SUCCESS, spell);
Queue(e);
}
void OnPacket(uint32_t opcode, CDataStore *p) {
if (opcode == SMSG_SPELL_START)
if (opcode == Offsets::SMSG_SPELL_START)
SpellStart(p);
else if (opcode == SMSG_SPELL_GO)
else if (opcode == Offsets::SMSG_SPELL_GO)
SpellGo(p);
}
+3 -2
View File
@@ -609,11 +609,12 @@ bool Ranged(const uint8_t *spell) {
uint32_t SchoolMask(uint32_t school) { return school < kSchoolCount ? 1u << school : 0; }
bool HasEffect(const uint8_t *spell, int effect) {
bool HasAuraEffect(const uint8_t *spell, int auraName) {
if (spell == nullptr)
return false;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
if (Game::Read<int>(spell, Offsets::OFF_SPELL_RECORD_EFFECT + i * 4) == effect)
if (Game::Read<int>(spell, Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME + i * 4) ==
auraName)
return true;
}
return false;
+6 -2
View File
@@ -41,6 +41,8 @@ enum Subevent : int {
SPELL_DRAIN = 12,
SPELL_LEECH = 13,
SPELL_INSTAKILL = 14,
SPELL_SUMMON = 15,
SPELL_CREATE = 16,
SPELL_INTERRUPT = 17,
SPELL_EXTRA_ATTACKS = 18,
SPELL_DURABILITY_DAMAGE = 19,
@@ -49,6 +51,7 @@ enum Subevent : int {
SPELL_AURA_APPLIED_DOSE = 22,
SPELL_AURA_REMOVED_DOSE = 23,
SPELL_AURA_REMOVED = 24,
SPELL_AURA_REFRESH = 25,
SPELL_DISPEL = 26,
SPELL_PERIODIC_MISSED = 33,
SPELL_PERIODIC_DAMAGE = 34,
@@ -155,8 +158,9 @@ bool Ranged(const uint8_t *spell);
// A school index (Spell.dbc School, the packets' school) as a school mask.
uint32_t SchoolMask(uint32_t school);
// Whether one of the spell's three effects is `effect`.
bool HasEffect(const uint8_t *spell, int effect);
// Whether one of the spell's three effects applies the aura `auraName`
// (EffectApplyAuraName).
bool HasAuraEffect(const uint8_t *spell, int auraName);
// GetCombatLogEntry: an entry for `event` from `source` to `dest` (either may
// be 0), with the spell prefix when `spell` is a record.
+72 -7
View File
@@ -17,7 +17,9 @@
//
// 0x24c SPELLLOGEXECUTE (handler 0x005E7F90): packed caster, spell, effect
// count; per effect its id and a target count, then per target what that
// effect carries. 1.12's reader is the layout, whatever the server meant:
// effect carries. The layout is the server's (tortoise-wow
// Spell::SendLogExecute), which 1.12's reader follows for every effect it
// knows:
// 8 (power drain) u64 target, amount, power, float multiplier
// 19 (extra attacks) u64 target, count
// 24 (create item) item
@@ -26,18 +28,27 @@
// 111 (durability) u64 target, item, slot (both -1: every item)
// 1, 18, 33, 38, 59, 63, 69, 79, 91, 102, 108, 113, 114, 116, 125, 126
// u64 target
// any other nothing
// 28, 41, 42, 50, 56, 73, 74, 76, 87-90, 97, 104-107, 112 (the summons)
// u64 summoned — the one block 1.12's reader skips:
// it reads nothing for these effects and leaves the
// GUID in the buffer, so the engine's own log never
// names a summon
// any other nothing (the server sends no message for it)
// 4.3.4 logs drains and leeches, resurrections (18, 113), extra attacks,
// interrupts and durability losses from these. Its summons have no target
// here, so SPELL_SUMMON and SPELL_CREATE do not occur.
// interrupts, durability losses and summons (SPELL_SUMMON for a unit,
// SPELL_CREATE for a game object) from these.
//
// 0x32f SPELLINSTAKILLLOG (0x005E85A0): the victim (u64) and the spell. 1.12
// names no caster, so the entry has no source.
// names no caster; the server sends it from the effect's handler, after the
// cast's SMSG_SPELL_GO in the same tick, so the source is the caster of the
// newest cast of that spell at the victim (RecentCasts), else none.
#include "combatlog/Entry.h"
#include "Game.h"
#include "Offsets.h"
#include "combatlog/RecentCasts.h"
#include "guid/Guid.h"
#include "net/PacketDispatch.h"
#include <cstdint>
@@ -59,18 +70,36 @@ enum Effect : uint32_t {
EFFECT_RESURRECT = 18,
EFFECT_ADD_EXTRA_ATTACKS = 19,
EFFECT_CREATE_ITEM = 24,
EFFECT_SUMMON = 28,
EFFECT_OPEN_LOCK = 33,
EFFECT_DISPEL = 38,
EFFECT_SUMMON_WILD = 41,
EFFECT_SUMMON_GUARDIAN = 42,
EFFECT_TRANS_DOOR = 50,
EFFECT_SUMMON_PET = 56,
EFFECT_OPEN_LOCK_ITEM = 59,
EFFECT_THREAT = 63,
EFFECT_INTERRUPT_CAST = 68,
EFFECT_DISTRACT = 69,
EFFECT_SUMMON_POSSESSED = 73,
EFFECT_SUMMON_TOTEM = 74,
EFFECT_SUMMON_OBJECT_WILD = 76,
EFFECT_SANCTUARY = 79,
EFFECT_SUMMON_TOTEM_SLOT1 = 87,
EFFECT_SUMMON_TOTEM_SLOT2 = 88,
EFFECT_SUMMON_TOTEM_SLOT3 = 89,
EFFECT_SUMMON_TOTEM_SLOT4 = 90,
EFFECT_THREAT_ALL = 91,
EFFECT_SUMMON_CRITTER = 97,
EFFECT_FEED_PET = 101,
EFFECT_DISMISS_PET = 102,
EFFECT_SUMMON_OBJECT_SLOT1 = 104,
EFFECT_SUMMON_OBJECT_SLOT2 = 105,
EFFECT_SUMMON_OBJECT_SLOT3 = 106,
EFFECT_SUMMON_OBJECT_SLOT4 = 107,
EFFECT_DISPEL_MECHANIC = 108,
EFFECT_DURABILITY_DAMAGE = 111,
EFFECT_SUMMON_DEMON = 112,
EFFECT_RESURRECT_NEW = 113,
EFFECT_ATTACK_ME = 114,
EFFECT_SKIN_PLAYER_CORPSE = 116,
@@ -107,6 +136,17 @@ void Simple(uint64_t caster, uint64_t target, int event, const uint8_t *spell) {
Queue(e);
}
// UnitCombatLogSummon: a game object or dynamic object is SPELL_CREATE, a
// unit SPELL_SUMMON. A portal writes nothing: every portal spell has
// Attributes 0x100 (verified in Spell.dbc: 11417, 11418, Turtle's 49362), the
// same bit that keeps it out of 1.12's own cast lines; Fishing does not.
void Summon(uint64_t caster, uint64_t summoned, const uint8_t *spell) {
if (summoned == 0)
return;
const bool object = Guid::IsGameObject(summoned) || Guid::IsDynamicObject(summoned);
Simple(caster, summoned, object ? SPELL_CREATE : SPELL_SUMMON, spell);
}
// UnitCombatLogExtraAttacks.
void ExtraAttacks(uint64_t caster, uint64_t target, const uint8_t *spell, int count) {
if (count == 0 || Hidden(spell))
@@ -185,6 +225,29 @@ bool Target(CDataStore *p, uint32_t effect, uint64_t caster, const uint8_t *spel
Simple(caster, target, SPELL_RESURRECT, spell);
break;
}
case EFFECT_SUMMON:
case EFFECT_SUMMON_WILD:
case EFFECT_SUMMON_GUARDIAN:
case EFFECT_TRANS_DOOR:
case EFFECT_SUMMON_PET:
case EFFECT_SUMMON_POSSESSED:
case EFFECT_SUMMON_TOTEM:
case EFFECT_SUMMON_OBJECT_WILD:
case EFFECT_SUMMON_TOTEM_SLOT1:
case EFFECT_SUMMON_TOTEM_SLOT2:
case EFFECT_SUMMON_TOTEM_SLOT3:
case EFFECT_SUMMON_TOTEM_SLOT4:
case EFFECT_SUMMON_CRITTER:
case EFFECT_SUMMON_OBJECT_SLOT1:
case EFFECT_SUMMON_OBJECT_SLOT2:
case EFFECT_SUMMON_OBJECT_SLOT3:
case EFFECT_SUMMON_OBJECT_SLOT4:
case EFFECT_SUMMON_DEMON: {
const uint64_t summoned = Read<uint64_t>(p);
if (!Overrun(p))
Summon(caster, summoned, spell);
break;
}
case EFFECT_CREATE_ITEM:
case EFFECT_FEED_PET:
Read<uint32_t>(p);
@@ -232,8 +295,10 @@ void SpellLogExecute(CDataStore *p) {
void InstakillLog(CDataStore *p) {
const uint64_t victim = Read<uint64_t>(p);
const int spellId = Read<int32_t>(p);
if (!Overrun(p))
Simple(0, victim, SPELL_INSTAKILL, SpellRecord(spellId));
if (Overrun(p))
return;
const uint64_t caster = RecentCasts::CasterOf(static_cast<uint32_t>(spellId), victim);
Simple(caster, victim, SPELL_INSTAKILL, SpellRecord(spellId));
}
void OnPacket(uint32_t opcode, CDataStore *p) {
+5 -17
View File
@@ -55,7 +55,6 @@ constexpr uint32_t CONTROL_MASK = 0x300;
constexpr uint32_t TYPE_MASK = 0xFC00;
constexpr int kMaxToken = 128;
constexpr int kStormMaxStr = 0x7FFFFFFF;
struct Filter {
uint64_t events = ~0ull; // bit per Subevent
@@ -79,20 +78,9 @@ constexpr int kDefaultRetention = 300;
// The longest retention, in seconds, that the wrap-safe age test can measure.
constexpr int kMaxRetention = 0x7FFFFFFF / 1000;
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
using Tokenize_t = void(__stdcall *)(char **cursor, char *out, unsigned outSize,
const char *delims, int *outQuoted);
using LooseBool_t = int(__fastcall *)(void *L, int idx, int dflt);
int StrCmpI(const char *a, const char *b) {
return reinterpret_cast<SStrCmpI_t>(static_cast<uintptr_t>(Offsets::FUN_SSTR_CMP_I))(
a, b, kStormMaxStr);
}
bool ArgFlag(void *L, int idx) {
return reinterpret_cast<LooseBool_t>(
static_cast<uintptr_t>(Offsets::FUN_LUA_TO_BOOLEAN_LOOSE))(L, idx, 0) != 0;
}
bool Complete(uint32_t matched) {
return (matched & AFFILIATION_MASK) && (matched & REACTION_MASK) && (matched & CONTROL_MASK) &&
@@ -145,7 +133,7 @@ uint64_t EventBits(const char *events) {
if (token[0] == '\0')
break;
for (int i = 0; i < EVENT_COUNT; ++i) {
if (StrCmpI(token, Name(i)) == 0) {
if (Game::StrCmpI(token, Name(i)) == 0) {
bits |= 1ull << i;
break;
}
@@ -175,7 +163,7 @@ bool Matches(const Filter &f, const Entry &e) {
if (!f.spellNamed)
return true;
const char *name = e.spellPrefix ? SpellName(SpellRecord(e.spellId)) : nullptr;
return name != nullptr && StrCmpI(name, f.spellName.c_str()) == 0;
return name != nullptr && Game::StrCmpI(name, f.spellName.c_str()) == 0;
}
bool Matches(const Entry &e, bool all) { return all || MatchesFilter(e); }
@@ -269,7 +257,7 @@ int __fastcall Script_CombatLogSetRetentionTime(void *L) {
}
int __fastcall Script_CombatLogGetNumEntries(void *L) {
const bool all = ArgFlag(L, 1);
const bool all = Game::Lua::ToFlag(L, 1);
int count = 0;
for (const Entry &e : g_entries) {
if (Matches(e, all))
@@ -286,7 +274,7 @@ int __fastcall Script_CombatLogSetCurrentEntry(void *L) {
}
g_current = -1;
int index = static_cast<int>(Game::Lua::ToNumber(L, 1));
const bool all = ArgFlag(L, 2);
const bool all = Game::Lua::ToFlag(L, 2);
if (index <= 0) {
// 0 is the newest entry, -1 the one before it.
for (int i = Count() - 1; i >= 0; --i) {
@@ -320,7 +308,7 @@ int __fastcall Script_CombatLogAdvanceEntry(void *L) {
return 0;
}
int count = static_cast<int>(Game::Lua::ToNumber(L, 1));
const bool all = ArgFlag(L, 2);
const bool all = Game::Lua::ToFlag(L, 2);
if (count != 0) {
// The walk starts at the current entry, which counts when it matches.
int result = -1;
+155 -7
View File
@@ -36,10 +36,13 @@
// resisted
// 0x260 PROCRESIST caster, target (u64), spell, u8 debug (two
// floats follow when it is set)
// What 1.12 does not send is filled as 4.3.4 fills "none": overkill and
// overhealing 0, healing absorbed 0, no periodic critical. Schools are
// indexes here and masks in the event. A damage shield names no spell, so
// its spell prefix is spellId 0 with the damage school.
// 0x263 SPELLORDAMAGE_IMMUNE caster, target (u64), spell, u8 debug
// What 1.12 does not send is filled as 4.3.4 fills "none": healing absorbed
// 0, no periodic critical. Overkill and overhealing, which 1.12 does not send
// either, are computed from the target's health (see Predicted). Schools are
// indexes here and masks in the event. A damage shield names no spell: the
// owner's one damage-shield aura of the damage's school names it, else its
// spell prefix is spellId 0 with the damage school.
//
// Spell misses do not come from here: they reach the log from three places
// (the SPELL_GO miss list, a missile's arrival, SPELLLOGMISS), all through
@@ -49,8 +52,10 @@
#include "Game.h"
#include "Offsets.h"
#include "aura/Data.h"
#include "net/PacketDispatch.h"
#include "object/Resolve.h"
#include "time/Clock.h"
#include <cstdint>
@@ -70,6 +75,7 @@ constexpr uint32_t SMSG_PROCRESIST = 0x260;
constexpr uint32_t SMSG_PERIODICAURALOG = 0x24e;
constexpr uint32_t SMSG_SPELLDAMAGESHIELD = 0x24f;
constexpr uint32_t SMSG_SPELLNONMELEEDAMAGELOG = 0x250;
constexpr uint32_t SMSG_SPELLORDAMAGE_IMMUNE = 0x263;
// 1.12 melee hit info (the swing line's own tests, 0x00629B60 / 0x00629D30).
constexpr uint32_t HITINFO_LEFTSWING = 0x4;
@@ -93,6 +99,9 @@ enum VictimState : uint32_t {
VICTIMSTATE_DEFLECTS = 8,
};
// The aura a damage shield is (SpellAuraNames 15).
constexpr int AURA_DAMAGE_SHIELD = 15;
// Periodic aura types (SpellAuraNames) the log reads.
constexpr uint32_t AURA_PERIODIC_DAMAGE = 3;
constexpr uint32_t AURA_PERIODIC_HEAL = 8;
@@ -114,6 +123,92 @@ bool PlayerControlled(const void *unit) {
Offsets::UNIT_FLAG_PLAYER_CONTROLLED) != 0;
}
// 4.3.4's ModifyPredictedHealth, for the overkill and overhealing 1.12 does
// not send. The server sends each damage and heal message from the handler
// that deals it, and the unit's health in the update that ends the tick, so
// a message's target still shows the health before it (verified live: a
// target at 148, 113, 91, 63, 37, 10 of 194 under Fireballs of 34, 22, 27,
// 26, 27, then the kill at 10 with a 26). Overkill is the damage past that
// health and overhealing the heal past the missing health — the server's own
// arithmetic, one tick early. A tick's second message on a unit must count
// its first, so the messages since the health field was last read are kept
// as a delta; a changed field is a fresh snapshot that already counts them.
struct Predicted {
uint64_t guid = 0;
int health = 0; // the field's value the delta was taken against
int delta = 0; // the messages since, as a change of health
uint32_t stampMs = 0;
};
constexpr int kPredictedMax = 16;
constexpr uint32_t kPredictedStaleMs = 2000;
Predicted g_predicted[kPredictedMax];
// A server that hides health sends a percentage with this maximum (tortoise-
// wow Object::BuildValuesUpdate with ShowHealthValues off — the stock setting,
// for every unit outside the player's group; Turtle shows real values). Its
// damage would read as overkill, so such a unit gets none.
constexpr int kHiddenMaxHealth = 100;
Predicted &PredictedFor(uint64_t guid, int health, uint32_t now) {
Predicted *oldest = &g_predicted[0];
for (Predicted &p : g_predicted) {
if (p.guid == guid) {
if (p.health != health || Time::Clock::Elapsed(p.stampMs, now) > kPredictedStaleMs) {
p.health = health;
p.delta = 0;
}
p.stampMs = now;
return p;
}
if (p.guid == 0 || static_cast<int32_t>(p.stampMs - oldest->stampMs) < 0)
oldest = &p;
}
*oldest = {guid, health, 0, now};
return *oldest;
}
// The target's health as the tick's earlier messages leave it, and its
// maximum. False for a unit out of view or one whose health is hidden.
bool PredictedHealth(uint64_t guid, int *health, int *max, Predicted **entry) {
const void *unit = Unit(guid);
const auto *fields =
unit != nullptr ? Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR) : nullptr;
if (fields == nullptr)
return false;
*max = Game::Read<int>(fields, Offsets::OFF_UNIT_FIELD_MAXHEALTH);
if (*max == kHiddenMaxHealth)
return false;
const int current = Game::Read<int>(fields, Offsets::OFF_UNIT_FIELD_HEALTH);
*entry = &PredictedFor(guid, current, Time::Clock::NowMs());
*health = current + (*entry)->delta;
return true;
}
// The damage past the target's health, counting the damage for the tick's
// later messages.
int Overkill(uint64_t dest, int damage) {
int health, max;
Predicted *p;
if (damage <= 0 || !PredictedHealth(dest, &health, &max, &p))
return 0;
const int overkill = damage > health ? damage - health : 0;
p->delta -= damage - overkill;
return overkill;
}
// The heal past the health the target is missing, counting the heal for the
// tick's later messages.
int Overhealing(uint64_t dest, int amount) {
int health, max;
Predicted *p;
if (amount <= 0 || !PredictedHealth(dest, &health, &max, &p))
return 0;
const int missing = max > health ? max - health : 0;
const int overhealing = amount > missing ? amount - missing : 0;
p->delta += amount - overhealing;
return overhealing;
}
// SendDamageEvent.
void SendDamage(uint64_t source, uint64_t dest, int event, const uint8_t *spell, int damage,
uint32_t schoolMask, int resisted, int blocked, int absorbed, bool critical,
@@ -121,7 +216,7 @@ void SendDamage(uint64_t source, uint64_t dest, int event, const uint8_t *spell,
Entry e = Begin(source, dest, event, spell);
e.info |= INFO_DAMAGE;
e.v[1] = damage;
e.v[2] = 0; // overkill: not sent
e.v[2] = Overkill(dest, damage);
e.v[3] = static_cast<int>(schoolMask);
e.v[4] = absorbed;
e.v[5] = resisted;
@@ -171,7 +266,7 @@ void Heal(uint64_t caster, uint64_t target, const uint8_t *spell, int amount, bo
Entry e = Begin(caster, target, periodic ? SPELL_PERIODIC_HEAL : SPELL_HEAL, spell);
e.info |= INFO_HEAL;
e.v[0] = amount;
e.v[1] = 0; // overhealing: not sent
e.v[1] = Overhealing(target, amount);
e.v[2] = 0; // absorbed: 1.12 has no healing absorbs
e.critical = critical;
Queue(e);
@@ -389,6 +484,32 @@ void PeriodicAuraLog(CDataStore *p) {
}
}
// The shield that dealt the damage: the one damage-shield aura of `school`
// among the owner's aura slots (Thorns, Fire Shield, Retribution Aura;
// Lightning Shield is a proc and arrives through the non-melee log). An owner
// out of view, or two shields of one school on one owner, name none. The
// shield is still on the owner: the message comes before any aura update.
const uint8_t *ShieldSpell(uint64_t owner, uint32_t school) {
const auto *unit = static_cast<const uint8_t *>(Unit(owner));
if (unit == nullptr)
return nullptr;
const uint8_t *found = nullptr;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
const uint32_t spellId = Aura::Data::ReadSpellID(unit, slot);
if (spellId == 0)
continue;
const uint8_t *spell = SpellRecord(static_cast<int>(spellId));
if (spell == nullptr ||
Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_SCHOOL) != school ||
!HasAuraEffect(spell, AURA_DAMAGE_SHIELD))
continue;
if (found != nullptr && found != spell)
return nullptr;
found = spell;
}
return found;
}
// SMSG_SPELLDAMAGESHIELD → UnitCombatLogDamageShield.
void DamageShield(CDataStore *p) {
const uint64_t owner = Read<uint64_t>(p);
@@ -397,11 +518,13 @@ void DamageShield(CDataStore *p) {
const uint32_t school = Read<uint32_t>(p);
if (Overrun(p))
return;
Entry e = Begin(owner, victim, DAMAGE_SHIELD, nullptr);
const uint8_t *spell = ShieldSpell(owner, school);
Entry e = Begin(owner, victim, DAMAGE_SHIELD, Hidden(spell) ? nullptr : spell);
e.spellPrefix = true;
e.spellSchool = SchoolMask(school);
e.info |= INFO_DAMAGE;
e.v[1] = damage;
e.v[2] = Overkill(victim, damage);
e.v[3] = static_cast<int>(SchoolMask(school));
Queue(e);
}
@@ -422,6 +545,7 @@ void EnvironmentalDamage(CDataStore *p) {
e.info |= INFO_ENVIRONMENTAL | INFO_DAMAGE;
e.v[0] = static_cast<int>(type);
e.v[1] = damage;
e.v[2] = Overkill(victim, damage);
e.v[3] = static_cast<int>(SchoolMask(type < 6 ? kSchools[type] : 0));
e.v[4] = absorbed;
e.v[5] = resisted;
@@ -441,6 +565,27 @@ void ProcResist(CDataStore *p) {
SendMiss(caster, target, SPELL_MISSED, spell, MISS_RESIST, 0, false);
}
// SMSG_SPELLORDAMAGE_IMMUNE → Packet_SMSG_SPELLORDAMAGE_IMMUNE: a target immune
// to the damage (a damage shield's, a periodic tick's). 4.3.4 passes it to
// UnitCombatLogSpellMissed as IMMUNE with damageShield = !debug and periodic =
// debug, so with the server's debug byte of 0 the event is
// DAMAGE_SHIELD_MISSED whatever dealt the damage; 3.3.5 does the same. 1.12's
// own line (0x0062D240, IMMUNESPELL…) comes from this handler alone, not from
// the miss writer Miss.cpp hooks.
void SpellOrDamageImmune(CDataStore *p) {
const uint64_t caster = Read<uint64_t>(p);
const uint64_t target = Read<uint64_t>(p);
const int spellId = Read<int32_t>(p);
const bool debug = Read<uint8_t>(p) != 0;
if (Overrun(p))
return;
const uint8_t *spell = SpellRecord(spellId);
if (Hidden(spell))
return;
SendMiss(caster, target, debug ? SPELL_PERIODIC_MISSED : DAMAGE_SHIELD_MISSED, spell,
MISS_IMMUNE, 0, false);
}
void OnPacket(uint32_t opcode, CDataStore *p) {
switch (opcode) {
case SMSG_ATTACKERSTATEUPDATE:
@@ -467,6 +612,9 @@ void OnPacket(uint32_t opcode, CDataStore *p) {
case SMSG_PROCRESIST:
ProcResist(p);
break;
case SMSG_SPELLORDAMAGE_IMMUNE:
SpellOrDamageImmune(p);
break;
default:
break;
}
+103
View File
@@ -0,0 +1,103 @@
// 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 "combatlog/RecentCasts.h"
#include "combatlog/Entry.h"
#include "spell/Lookup.h"
#include "time/Clock.h"
#include <cstdint>
namespace CombatLog::RecentCasts {
namespace {
// A cast is of use for the tick that follows its go; the window is a server
// tick with room for the messages of one tick to reach the client in two
// frames. A match beyond it would be a later cast of the same kind.
constexpr uint32_t kWindowMs = 1000;
constexpr int kRecords = 16;
struct Cast {
uint32_t timeMs = 0;
uint64_t caster = 0;
uint32_t spellId = 0;
int hitCount = 0;
uint64_t hits[MAX_HITS] = {};
};
Cast g_casts[kRecords];
int g_next = 0; // the slot the next record takes; the newest is the one before
bool Hit(const Cast &c, uint64_t target) {
for (int i = 0; i < c.hitCount; ++i) {
if (c.hits[i] == target)
return true;
}
return false;
}
// Visits the records newest first while `fn` returns false.
template <typename Fn> void ForEachRecent(Fn &&fn) {
const uint32_t now = Time::Clock::NowMs();
for (int i = 0; i < kRecords; ++i) {
const Cast &c = g_casts[(g_next - 1 - i + kRecords) % kRecords];
if (c.spellId == 0 || Time::Clock::Elapsed(c.timeMs, now) > kWindowMs)
return;
if (fn(c))
return;
}
}
} // namespace
void Record(uint64_t caster, uint32_t spellId, const uint64_t *hits, int count) {
if (spellId == 0 || count <= 0)
return;
Cast &c = g_casts[g_next];
g_next = (g_next + 1) % kRecords;
c.timeMs = Time::Clock::NowMs();
c.caster = caster;
c.spellId = spellId;
c.hitCount = count < MAX_HITS ? count : MAX_HITS;
for (int i = 0; i < c.hitCount; ++i)
c.hits[i] = hits[i];
}
const uint8_t *SpellByEffect(uint64_t caster, uint64_t target, int effectA, int effectB) {
const uint8_t *found = nullptr;
ForEachRecent([&](const Cast &c) {
if (c.caster != caster || !Hit(c, target))
return false;
const uint8_t *spell = SpellRecord(static_cast<int>(c.spellId));
if (!Spell::Lookup::HasEffect(spell, effectA) && !Spell::Lookup::HasEffect(spell, effectB))
return false;
found = spell;
return true;
});
return found;
}
uint64_t CasterOf(uint32_t spellId, uint64_t target) {
uint64_t found = 0;
ForEachRecent([&](const Cast &c) {
if (c.spellId != spellId || !Hit(c, target))
return false;
found = c.caster;
return true;
});
return found;
}
} // namespace CombatLog::RecentCasts
+43
View File
@@ -0,0 +1,43 @@
// 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
// The casts just seen — the SMSG_SPELL_GO hit lists of the last second.
//
// 1.12's dispel and instant-kill messages name less than 4.3.4's: a dispel
// says who dispelled what from whom but not with which spell, and an instant
// kill names the victim and the spell but no caster. The server writes each of
// them from the spell's effect handler, in the same tick as, and after, that
// spell's SMSG_SPELL_GO (Spell::cast sends the go before handle_immediate). So
// the cast that caused a message is the newest recorded cast that fits it:
// by caster and target with a dispel effect, or by spell and target.
#include <cstdint>
namespace CombatLog::RecentCasts {
// The most hit units one record keeps.
constexpr int MAX_HITS = 16;
// Keeps a cast's caster, spell and the units it hit.
void Record(uint64_t caster, uint32_t spellId, const uint64_t *hits, int count);
// The spell of the newest recorded cast by `caster` that hit `target` and
// has effect `effectA` or `effectB`, as a Spell.dbc record, or nullptr.
const uint8_t *SpellByEffect(uint64_t caster, uint64_t target, int effectA, int effectB);
// The caster of the newest recorded cast of `spellId` that hit `target`, or 0.
uint64_t CasterOf(uint32_t spellId, uint64_t target);
} // namespace CombatLog::RecentCasts
+23
View File
@@ -78,6 +78,27 @@ int __fastcall Script_ConsoleEcho(void *L) {
return 0;
}
// `ConsoleAddMessage(message)` — 4.3.4's Script_ConsoleAddMessage
// (0x008C38B0): a usage error for a non-string, else ConsolePrintf("%s")
// unless the text is empty.
int __fastcall Script_ConsoleAddMessage(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: ConsoleAddMessage(string)");
return 0;
}
const char *text = Game::Lua::ToString(L, 1);
if (text != nullptr && *text != '\0')
Game::Console::Printf("%s", text);
return 0;
}
const Game::Doc::Field kConsoleAddMessageArgs[] = {
Game::Doc::Req("message", "string", "The text to write."),
};
const Game::Doc::Function kConsoleAddMessage{
"Writes a line of text to the console.",
kConsoleAddMessageArgs, {}, "ConsoleGlobals"};
// `ConsoleIsActive()` — whether the console is open right now.
//
// False when the client was started without `-console`, since the toggle key
@@ -208,6 +229,8 @@ int __fastcall Script_CalculateStringEditDistance(void *L) {
void Register() {
Game::Lua::RegisterGlobalFunction("ConsoleExec", &Script_ConsoleExec);
Game::Lua::RegisterGlobalFunction("ConsoleEcho", &Script_ConsoleEcho);
Game::Lua::RegisterGlobalFunction("ConsoleAddMessage", &Script_ConsoleAddMessage,
&kConsoleAddMessage);
Game::Lua::RegisterGlobalFunction("ConsoleIsActive", &Script_ConsoleIsActive);
Game::Lua::RegisterGlobalFunction("ConsoleGetColorFromType",
&Script_ConsoleGetColorFromType);
+15 -1
View File
@@ -62,9 +62,23 @@ int __fastcall Script_C_Container_AutoStoreItem(void *L) {
return 1;
}
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("srcBag", "BagIndex"),
Game::Doc::Req("srcSlot", "luaIndex"),
Game::Doc::Opt("dstBag", "BagIndex", "0",
"0 for anywhere in the bags, 1 to 4 for one bag, -1 for the bank."),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Req("sent", "bool", "False when the server cannot be asked for this move."),
};
const Game::Doc::Function kAutoStoreItem{
"Moves an item and lets the server choose where it goes, merging it into "
"existing stacks first.",
kArgs, kRets, nullptr, true};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "AutoStoreItem",
&Script_C_Container_AutoStoreItem);
&Script_C_Container_AutoStoreItem, &kAutoStoreItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+13 -1
View File
@@ -43,9 +43,21 @@ int __fastcall Script_C_Container_GetContainerItemCharges(void *L) {
L, Item::Location::ResolveBag(L, bagID, slotIndex));
}
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("containerIndex", "BagIndex"),
Game::Doc::Req("slotIndex", "luaIndex"),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Opt("uses", "number", nullptr, "Nil when the slot is empty."),
};
const Game::Doc::Function kGetContainerItemCharges{
"Returns the uses left on the item in a bag slot: its charges times its stack size.",
kArgs, kRets, nullptr, true};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetContainerItemCharges",
&Script_C_Container_GetContainerItemCharges);
&Script_C_Container_GetContainerItemCharges,
&kGetContainerItemCharges);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+14 -1
View File
@@ -33,9 +33,22 @@ int __fastcall Script_C_Container_GetItemCooldown(void *L) {
return 3;
}
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("itemID", "number"),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Req("startTime", "number",
"When the cooldown started, in GetTime() seconds; 0 when none."),
Game::Doc::Req("duration", "number", "Length in seconds; 0 when there is no cooldown."),
Game::Doc::Req("enable", "number",
"0 while the cooldown waits to start, as for a potion used in combat; else 1."),
};
const Game::Doc::Function kGetItemCooldown{
"Returns the cooldown of an item's use effect.", kArgs, kRets};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetItemCooldown",
&Script_C_Container_GetItemCooldown);
&Script_C_Container_GetItemCooldown, &kGetItemCooldown);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+14 -1
View File
@@ -40,9 +40,22 @@ int __fastcall Script_C_Container_GetContainerItemDurability(void *L) {
L, Item::Location::ResolveBag(L, bagID, slotIndex));
}
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("containerIndex", "BagIndex"),
Game::Doc::Req("slotIndex", "luaIndex"),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Opt("durability", "number", nullptr,
"Nil when the slot is empty or the item has no durability."),
Game::Doc::Opt("maxDurability", "number"),
};
const Game::Doc::Function kGetContainerItemDurability{
"Returns the current and maximum durability of the item in a bag slot.", kArgs, kRets};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetContainerItemDurability",
&Script_C_Container_GetContainerItemDurability);
&Script_C_Container_GetContainerItemDurability,
&kGetContainerItemDurability);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+16 -1
View File
@@ -80,10 +80,25 @@ int __fastcall Script_C_Container_GetContainerItemEquipmentSetInfo(void *L) {
return 2;
}
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("containerIndex", "BagIndex"),
Game::Doc::Req("slotIndex", "luaIndex"),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Req("inSet", "bool"),
Game::Doc::Opt("setList", "string", nullptr,
"The set names joined by PLAYER_LIST_DELIMITER; nil when inSet is false."),
};
const Game::Doc::Function kGetContainerItemEquipmentSetInfo{
"Returns whether the item in a bag slot is saved in an equipment set, and the "
"names of those sets.",
kArgs, kRets};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction(
"C_Container", "GetContainerItemEquipmentSetInfo",
&Script_C_Container_GetContainerItemEquipmentSetInfo);
&Script_C_Container_GetContainerItemEquipmentSetInfo,
&kGetContainerItemEquipmentSetInfo);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+33 -3
View File
@@ -182,15 +182,45 @@ int __fastcall Script_C_Container_CalculateTotalNumberOfFreeBagSlots(void *L) {
return 1;
}
const Game::Doc::Field kBagArgs[] = {
Game::Doc::Req("bagIndex", "BagIndex", "0 for the backpack, 1 to 4 for the equipped bags."),
};
const Game::Doc::Field kNumFreeSlotsRets[] = {
Game::Doc::Req("numFreeSlots", "number", "0 when the bag does not exist."),
Game::Doc::Req("bagFamily", "number",
"The bag's family bitmask; 0 for a bag that takes any item."),
};
const Game::Doc::Function kGetContainerNumFreeSlots{
"Returns the number of empty slots in a bag and the kind of items the bag takes.",
kBagArgs, kNumFreeSlotsRets};
const Game::Doc::Field kFreeSlotsRets[] = {
Game::Doc::Opt("freeSlots", "table", nullptr,
"The empty slot numbers in ascending order; nil when the bag does not exist."),
};
const Game::Doc::Function kGetContainerFreeSlots{
"Returns the numbers of the empty slots in a bag.", kBagArgs, kFreeSlotsRets};
const Game::Doc::Field kTotalRets[] = {
Game::Doc::Req("totalFreeSlots", "number"),
};
const Game::Doc::Function kCalculateTotalNumberOfFreeBagSlots{
"Returns the number of empty slots in the backpack and the equipped bags that take "
"any item.",
{}, kTotalRets};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetContainerNumFreeSlots",
&Script_C_Container_GetContainerNumFreeSlots);
&Script_C_Container_GetContainerNumFreeSlots,
&kGetContainerNumFreeSlots);
Game::Lua::RegisterTableFunction("C_Container", "GetContainerFreeSlots",
&Script_C_Container_GetContainerFreeSlots);
&Script_C_Container_GetContainerFreeSlots,
&kGetContainerFreeSlots);
Game::Lua::RegisterTableFunction("C_Container", "CalculateTotalNumberOfFreeBagSlots",
&Script_C_Container_CalculateTotalNumberOfFreeBagSlots);
&Script_C_Container_CalculateTotalNumberOfFreeBagSlots,
&kCalculateTotalNumberOfFreeBagSlots);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+12 -1
View File
@@ -40,9 +40,20 @@ int __fastcall Script_C_Container_HasContainerItem(void *L) {
return 1;
}
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("containerIndex", "BagIndex"),
Game::Doc::Req("slotIndex", "luaIndex"),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Req("hasItem", "bool"),
};
const Game::Doc::Function kHasContainerItem{
"Returns whether a bag slot holds an item, even one whose data is not cached yet.",
kArgs, kRets};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "HasContainerItem",
&Script_C_Container_HasContainerItem);
&Script_C_Container_HasContainerItem, &kHasContainerItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+16 -2
View File
@@ -117,13 +117,27 @@ int __fastcall Script_C_Container_UseHearthstone(void *L) {
return 1;
}
const Game::Doc::Field kHasRets[] = {
Game::Doc::Opt("itemID", "number", nullptr, "Nil when no hearthstone is in the bags."),
};
const Game::Doc::Function kPlayerHasHearthstone{
"Returns the ID of the hearthstone in the player's bags.", {}, kHasRets};
const Game::Doc::Field kUseRets[] = {
Game::Doc::Req("used", "bool",
"True when a hearthstone was found; the cast itself can still fail."),
};
const Game::Doc::Function kUseHearthstone{"Uses the hearthstone in the player's bags.", {},
kUseRets};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "PlayerHasHearthstone",
&Script_C_Container_PlayerHasHearthstone);
&Script_C_Container_PlayerHasHearthstone,
&kPlayerHasHearthstone);
Game::Lua::RegisterTableFunction("C_Container", "UseHearthstone",
&Script_C_Container_UseHearthstone);
&Script_C_Container_UseHearthstone, &kUseHearthstone);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+11 -1
View File
@@ -41,9 +41,19 @@ int __fastcall Script_C_Container_GetContainerItemID(void *L) {
return 1;
}
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("containerIndex", "BagIndex"),
Game::Doc::Req("slotIndex", "luaIndex"),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Opt("itemID", "number", nullptr, "Nil when the slot is empty."),
};
const Game::Doc::Function kGetContainerItemID{"Returns the ID of the item in a bag slot.",
kArgs, kRets};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetContainerItemID",
&Script_C_Container_GetContainerItemID);
&Script_C_Container_GetContainerItemID, &kGetContainerItemID);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+57 -7
View File
@@ -35,7 +35,7 @@
// vanilla can't track post-loot dynamic emptiness client-side).
// hyperlink — engine link builder via `Item::Link::FromCGItem` (enchant /
// random-suffix decorated, same as `GetContainerItemLink`).
// isFiltered — always false (vanilla has no bag search-filter system).
// isFiltered — the bag search's verdict (`Container::Search`).
// hasNoValue — ItemStats sellPrice == 0.
// itemID — instance block itemID (`Item::ID::FromCGItem`).
// isBound — CGItem descriptor SOULBOUND flag (`C_Item.IsBound`'s bit).
@@ -46,9 +46,16 @@
// name) are omitted/nil when the item's static data isn't cached yet — the
// live per-instance fields (count / locked / bound / itemID / link) are always
// present. Passive: does not warm the item cache.
//
// ClassicAPI extension: a table as the 3rd argument is filled and returned
// instead of a new one. Bag addons call this for every slot on every
// BAG_UPDATE, and in Lua 5.0 a table per call is steady garbage-collector
// work; the fill clears the optional fields this item lacks. An empty slot
// still returns nil and leaves the table alone.
#include "Game.h"
#include "Offsets.h"
#include "container/Search.h"
#include "item/CGItem.h"
#include "item/ID.h"
#include "item/Icon.h"
@@ -82,10 +89,10 @@ int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
"Usage: C_Container.GetContainerItemInfo(containerIndex, slotIndex)");
return 0;
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
const bool fill = Game::Lua::Type(L, 3) == Game::Lua::TYPE_TABLE;
const uint8_t *item = Item::Location::ResolveBag(L, bagID, slotIndex);
// Leaves the stack as it is, so a `buf` at index 3 is still there below.
const uint8_t *item = Item::Location::ResolveBagArgs(L);
if (item == nullptr) {
Game::Lua::PushNil(L); // empty slot / bad index → nil (modern contract)
return 1;
@@ -149,30 +156,73 @@ int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
itemName = baseName;
}
const bool isFiltered = Container::Search::IsItemFiltered(item);
// --- Build the ContainerItemInfo table (only stack-safe SetField* now). ---
Game::Lua::NewTable(L);
if (fill)
Game::Lua::PushValue(L, 3);
else
Game::Lua::NewTable(L);
if (haveIcon)
Game::Lua::SetFieldString(L, "iconFileID", iconPath);
else if (fill)
Game::Lua::SetFieldNil(L, "iconFileID");
Game::Lua::SetFieldNumber(L, "stackCount", static_cast<double>(stackCount));
Game::Lua::SetFieldBool(L, "isLocked", isLocked);
if (haveQuality)
Game::Lua::SetFieldNumber(L, "quality", static_cast<double>(quality));
else if (fill)
Game::Lua::SetFieldNil(L, "quality");
Game::Lua::SetFieldBool(L, "isReadable", isReadable);
Game::Lua::SetFieldBool(L, "hasLoot", hasLoot);
if (hyperlink != nullptr && hyperlink[0] != '\0')
Game::Lua::SetFieldString(L, "hyperlink", hyperlink);
Game::Lua::SetFieldBool(L, "isFiltered", false);
else if (fill)
Game::Lua::SetFieldNil(L, "hyperlink");
Game::Lua::SetFieldBool(L, "isFiltered", isFiltered);
Game::Lua::SetFieldBool(L, "hasNoValue", hasNoValue);
Game::Lua::SetFieldNumber(L, "itemID", static_cast<double>(itemID));
Game::Lua::SetFieldBool(L, "isBound", isBound);
if (itemName != nullptr)
Game::Lua::SetFieldString(L, "itemName", itemName);
else if (fill)
Game::Lua::SetFieldNil(L, "itemName");
return 1;
}
const Game::Doc::Field kContainerItemInfoFields[] = {
Game::Doc::Opt("iconFileID", "string", nullptr,
"Icon texture path. Nil until the item's data is cached."),
Game::Doc::Req("stackCount", "number"),
Game::Doc::Req("isLocked", "bool"),
Game::Doc::Opt("quality", "ItemQuality", nullptr, "Nil until the item's data is cached."),
Game::Doc::Req("isReadable", "bool"),
Game::Doc::Req("hasLoot", "bool"),
Game::Doc::Opt("hyperlink", "string"),
Game::Doc::Req("isFiltered", "bool", "True when the bag search hides the item."),
Game::Doc::Req("hasNoValue", "bool"),
Game::Doc::Req("itemID", "number"),
Game::Doc::Req("isBound", "bool"),
Game::Doc::Opt("itemName", "string", nullptr, "Nil until the item's data is cached."),
};
const Game::Doc::Structure kContainerItemInfo{"ContainerItemInfo", "Container",
kContainerItemInfoFields};
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("containerIndex", "BagIndex"),
Game::Doc::Req("slotIndex", "luaIndex"),
Game::Doc::Opt("buf", "table", nullptr,
"A table to write the item into and return instead of a new one."),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Opt("containerInfo", "ContainerItemInfo", nullptr, "Nil when the slot is empty."),
};
const Game::Doc::Function kGetContainerItemInfo{"The item in a bag slot.", kArgs, kRets};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetContainerItemInfo",
&Script_C_Container_GetContainerItemInfo);
&Script_C_Container_GetContainerItemInfo,
&kGetContainerItemInfo);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+16 -2
View File
@@ -57,9 +57,23 @@ int __fastcall Script_C_Container_MoveItem(void *L) {
return 1;
}
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("srcBag", "BagIndex"),
Game::Doc::Req("srcSlot", "luaIndex"),
Game::Doc::Req("dstBag", "BagIndex"),
Game::Doc::Req("dstSlot", "luaIndex"),
Game::Doc::Req("count", "number", "From 1 to 255."),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Req("sent", "bool", "False when an argument is not valid."),
};
const Game::Doc::Function kMoveItem{
"Moves part of a stack to another bag slot without the cursor.", kArgs, kRets, nullptr,
true};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "MoveItem",
&Script_C_Container_MoveItem);
Game::Lua::RegisterTableFunction("C_Container", "MoveItem", &Script_C_Container_MoveItem,
&kMoveItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+16 -1
View File
@@ -37,9 +37,24 @@ int __fastcall Script_C_Container_IsContainerItemOpenable(void *L) {
L, Item::Location::ResolveBag(L, bagID, slotIndex));
}
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("containerIndex", "BagIndex"),
Game::Doc::Req("slotIndex", "luaIndex"),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Opt("isOpenable", "bool", nullptr,
"Nil when the slot is empty or the item's data is not cached yet."),
Game::Doc::Opt("canOpen", "bool", nullptr, "False while the item is still locked."),
};
const Game::Doc::Function kIsContainerItemOpenable{
"Returns whether the item in a bag slot can be opened, and whether the player can "
"open it now.",
kArgs, kRets, nullptr, true};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "IsContainerItemOpenable",
&Script_C_Container_IsContainerItemOpenable);
&Script_C_Container_IsContainerItemOpenable,
&kIsContainerItemOpenable);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+52
View File
@@ -0,0 +1,52 @@
// 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/>.
// `SetBagPortraitTexture(texture, bagID)` and its retail home
// `C_Container.SetBagPortraitTexture` — puts the round portrait of an
// equipped bag's icon on a texture.
//
// The client already has this function, misspelled `SetBagPortaitTexture`
// (the stock ContainerFrame calls it by that name), with the same body as
// 4.3.4's. Both names here bind that engine function; its error text keeps
// the client's spelling.
#include "Game.h"
#include "Offsets.h"
namespace Container::Portrait {
namespace {
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("texture", "SimpleTexture"),
Game::Doc::Req("bagIndex", "BagIndex"),
};
const Game::Doc::Function kSetBagPortraitTexture{
"Sets a texture to the round portrait of an equipped bag's icon.", kArgs, {},
"ContainerGlobals"};
const Game::Doc::Function kCContainerSetBagPortraitTexture{
"Sets a texture to the round portrait of an equipped bag's icon.", kArgs, {}};
void RegisterLuaFunctions() {
const auto fn =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_SCRIPT_SET_BAG_PORTAIT_TEXTURE);
Game::Lua::RegisterGlobalFunction("SetBagPortraitTexture", fn, &kSetBagPortraitTexture);
Game::Lua::RegisterTableFunction("C_Container", "SetBagPortraitTexture", fn,
&kCContainerSetBagPortraitTexture);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Container::Portrait
+22 -6
View File
@@ -49,10 +49,6 @@ namespace Container::QuestInfo {
namespace {
// ItemClass.dbc row 12. Only this file cares which class means "quest",
// so the code stays local per the single-use rule in the offsets policy.
constexpr uint32_t kItemClassQuest = 12;
int __fastcall Script_C_Container_GetContainerItemQuestInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L,
@@ -79,7 +75,7 @@ int __fastcall Script_C_Container_GetContainerItemQuestInfo(void *L) {
} else {
isQuestItem = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS) ==
kItemClassQuest;
Offsets::ITEM_CLASS_QUEST;
startQuest = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_START_QUEST);
}
@@ -101,9 +97,29 @@ int __fastcall Script_C_Container_GetContainerItemQuestInfo(void *L) {
return 1;
}
const Game::Doc::Field kItemQuestInfoFields[] = {
Game::Doc::Req("isQuestItem", "bool"),
Game::Doc::Opt("questID", "number", nullptr,
"The quest the item begins; nil when it begins none."),
Game::Doc::Req("isActive", "bool", "True when the quest in questID is in the quest log."),
};
const Game::Doc::Structure kItemQuestInfo{"ItemQuestInfo", "Container", kItemQuestInfoFields};
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("containerIndex", "BagIndex"),
Game::Doc::Req("slotIndex", "luaIndex"),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Req("questInfo", "ItemQuestInfo",
"Always a table; an empty slot reports no quest."),
};
const Game::Doc::Function kGetContainerItemQuestInfo{
"Returns how the item in a bag slot relates to quests.", kArgs, kRets};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetContainerItemQuestInfo",
&Script_C_Container_GetContainerItemQuestInfo);
&Script_C_Container_GetContainerItemQuestInfo,
&kGetContainerItemQuestInfo);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+13 -1
View File
@@ -37,9 +37,21 @@ int __fastcall Script_C_Container_GetContainerItemRepairCost(void *L) {
L, Item::Location::ResolveBag(L, bagID, slotIndex));
}
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("containerIndex", "BagIndex"),
Game::Doc::Req("slotIndex", "luaIndex"),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Req("repairCost", "number", "In copper; 0 when the item needs no repair."),
};
const Game::Doc::Function kGetContainerItemRepairCost{
"Returns what a merchant charges to repair the item in a bag slot.", kArgs, kRets,
nullptr, true};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetContainerItemRepairCost",
&Script_C_Container_GetContainerItemRepairCost);
&Script_C_Container_GetContainerItemRepairCost,
&kGetContainerItemRepairCost);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+313
View File
@@ -0,0 +1,313 @@
// 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_Container.SetItemSearch(searchString)`,
// `C_Container.IsContainerFiltered(containerIndex)` and
// `INVENTORY_SEARCH_UPDATE` — 4.3.4's bag search (`CGContainerInfo`,
// SetItemSearch 0x009BB230, IsItemFiltered 0x009BE790, ItemRecIsFiltered
// 0x009BD3C0, Script_IsContainerFiltered 0x009BE8D0). It also feeds the
// `isFiltered` field of `C_Container.GetContainerItemInfo`.
//
// One search covers every container. `SetItemSearch` folds the text once
// (case, and accents for the client's locale) and fires the event when the
// folded text changes; an empty string clears it. An item matches when the
// folded text is a substring of any of these, in 4.3.4's order:
// 1. the name of an equipment set that holds this very item (by GUID, so a
// second copy of the same itemID does not count);
// 2. its full name, random suffix included;
// 3. its subclass's short name ("Bow", "Cloth") — skipped for cloaks and
// for the rows flagged ITEMSUBCLASS_FLAG_NO_SEARCH_NAME (the melee
// weapons), as 4.3.4 skips them;
// 4. its equip slot's name ("Head", "Two-Hand").
// An item whose static data isn't cached counts as a match, as in 4.3.4.
// Nothing re-checks when that data arrives: UIs re-read on BAG_UPDATE and on
// INVENTORY_SEARCH_UPDATE, which is all 4.3.4's FrameXML does.
//
// Two departures from 4.3.4:
// - 4.3.4 folds the search text with its CHARACTER count as the byte size,
// which cuts a non-ASCII search short; this folds the whole string.
// - The keyring (-2) is searched like any bag: 1.12 still has it, and
// GetContainerItemInfo already reports its slots. 4.3.4 had dropped it,
// so its IsContainerFiltered reported it filtered.
//
// The search lives in the engine in 4.3.4, not the UI, so it survives
// /reload; logging out clears it without the event (4.3.4's LeaveWorld).
#include "container/Search.h"
#include "Game.h"
#include "Offsets.h"
#include "equipmentset/Data.h"
#include "event/Custom.h"
#include "item/CGItem.h"
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include "item/Record.h"
#include "item/TypeInfo.h"
#include "unit/Identity.h"
#include <algorithm>
#include <climits>
#include <cstdint>
#include <string>
#include <string_view>
namespace Container::Search {
namespace {
// 4.3.4's fold buffers hold 512 UTF-16 units; names and search text fit.
constexpr size_t kFoldCap = 512;
// The folded search text; empty means no search.
std::u16string g_search;
// 4.3.4's ToLower (0x004E6930): ASCII, Latin-1 0xC0..0xDE, Œ, Cyrillic.
char16_t ToLower(char16_t c) {
if (static_cast<uint16_t>(c - u'A') <= 25 || static_cast<uint16_t>(c - 0xC0) <= 30)
return static_cast<char16_t>(c + 32);
if (c == 0x152)
return 0x153;
if (static_cast<uint16_t>(c - 0x410) <= 31)
return static_cast<char16_t>(c + 32);
if (c == 0x401)
return 0x451;
return c;
}
// Appends the lowercased `c` with 4.3.4's accent folding for `locale`
// (frFR 2, deDE 3, esES/esMX 6/7, ruRU 8); æ, œ and ß become two letters.
// Returns the new length.
size_t AppendFolded(int locale, char16_t c, char16_t *out, size_t n) {
switch (locale) {
case 2: // frFR
switch (c) {
case 0xE0: case 0xE2: case 0xE4: c = u'a'; break;
case 0xE6: out[n++] = u'a'; c = u'e'; break;
case 0xE7: c = u'c'; break;
case 0xE8: case 0xE9: case 0xEA: case 0xEB: c = u'e'; break;
case 0xEE: case 0xEF: c = u'i'; break;
case 0xF2: case 0xF3: case 0xF4: case 0xF6: c = u'o'; break;
case 0x153: out[n++] = u'o'; c = u'e'; break;
case 0xF9: case 0xFA: case 0xFB: case 0xFC: c = u'u'; break;
}
break;
case 3: // deDE
if (c == 0xDF) {
out[n++] = u's';
c = u's';
}
break;
case 6: // esES
case 7: // esMX
switch (c) {
case 0xE1: c = u'a'; break;
case 0xE9: c = u'e'; break;
case 0xED: c = u'i'; break;
case 0xF3: c = u'o'; break;
case 0xFA: case 0xFC: c = u'u'; break;
case 0xF1: c = u'n'; break;
}
break;
case 8: // ruRU
if (c == 0x451)
c = 0x435;
break;
}
out[n++] = c;
return n;
}
// 4.3.4's CaseAccentInsensitiveParse (0x004E6990): UTF-8 → UTF-16 through the
// engine's converter, lowercased, then accent-folded. Writes `out`
// NUL-terminated and returns its length. 4.3.4 leaves koKR/zhCN/zhTW
// unlowercased and lets its case-insensitive SStrStrUTF8I match them; this
// lowercases every locale so a plain compare matches the same way.
size_t Fold(const char *text, char16_t (&out)[kFoldCap]) {
using Convert_t = int(__stdcall *)(char16_t *dst, uint32_t cap, const char *src,
uint32_t len, uint32_t *written, uint32_t *read);
char16_t wide[kFoldCap] = {};
// One unit short of the buffer: a full conversion stores no terminator.
reinterpret_cast<Convert_t>(Offsets::FUN_SUNI_CONVERT_UTF8_TO_16)(
wide, kFoldCap - 1, text, 0x7FFFFFFF, nullptr, nullptr);
const int locale =
*reinterpret_cast<const int *>(static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
size_t n = 0;
for (size_t i = 0; wide[i] != 0 && n + 2 < kFoldCap; ++i)
n = AppendFolded(locale, ToLower(wide[i]), out, n);
out[n] = 0;
return n;
}
// Whether the folded `text` contains the search.
bool Contains(const char *text) {
if (text == nullptr || text[0] == '\0')
return false;
char16_t folded[kFoldCap];
const size_t n = Fold(text, folded);
return std::u16string_view(folded, n).find(g_search) != std::u16string_view::npos;
}
// Whether an equipment set that holds this very item has a matching name.
// The GUID test goes first: it is cheap, and most items are in no set.
bool InMatchingSet(const uint8_t *item) {
const uint64_t guid = Item::Guid(item);
if (guid == EquipmentSet::GUID_EMPTY || guid == EquipmentSet::GUID_IGNORED)
return false;
for (const EquipmentSet::Set &set : EquipmentSet::Data::All()) {
const uint64_t *end = set.items + EquipmentSet::SLOT_COUNT;
if (std::find(set.items, end, guid) != end && Contains(set.name.c_str()))
return true;
}
return false;
}
// IsItemFiltered for an active search, with the equipment sets loaded.
bool Filtered(const uint8_t *item) {
if (InMatchingSet(item))
return false;
const int itemID = Item::ID::FromCGItem(item);
const uint8_t *record =
itemID > 0 ? Item::PeekRecord(static_cast<uint32_t>(itemID)) : nullptr;
if (record == nullptr)
return false;
char name[256];
if (Item::Link::NameFromCGItem(item, name, sizeof(name)) && Contains(name))
return false;
const int invType = Game::Read<int>(record, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
const uint8_t *subClass = Item::TypeInfo::FindSubClass(
Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_CLASS),
Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SUBCLASS));
if (subClass != nullptr && invType != Offsets::INVTYPE_CLOAK &&
(Game::Read<uint32_t>(subClass, Offsets::OFF_ITEMSUBCLASS_FLAGS) &
Offsets::ITEMSUBCLASS_FLAG_NO_SEARCH_NAME) == 0 &&
Contains(Item::TypeInfo::SubClassShortName(subClass)))
return false;
return !Contains(Item::TypeInfo::InvTypeName(invType));
}
} // namespace
bool IsItemFiltered(const uint8_t *cgItem) {
if (g_search.empty() || cgItem == nullptr)
return false;
EquipmentSet::Data::EnsureLoaded();
return Filtered(cgItem);
}
namespace {
// Lua's BACKPACK_CONTAINER, BANK_CONTAINER, KEYRING_CONTAINER and the last
// bank bag (NUM_BAG_SLOTS + NUM_BANKBAGSLOTS).
constexpr int kBackpack = 0;
constexpr int kBank = -1;
constexpr int kKeyring = -2;
constexpr int kLastBankBag = 10;
const Game::Doc::Event kSearchUpdateDoc{
"Container", "Fires when the bag search text changes.", {}};
const Event::Custom::AutoReserve _evt{"INVENTORY_SEARCH_UPDATE", &kSearchUpdateDoc};
// How many slots of `bagID` to walk. PackBagSlot bounds a bag by the bag's
// own size, but the backpack and the main bank are ranges of the player's
// inventory that it bounds only by the whole inventory's size, so those two
// get their fixed sizes. The keyring is the inventory's last range, so the
// whole-inventory bound is its own. Any other index is no container.
int SlotsToWalk(int bagID) {
if (bagID == kBackpack)
return Offsets::BACKPACK_NUM_SLOTS;
if (bagID == kBank)
return Offsets::INVMGR_BANK_MAIN_LAST_SLOT - Offsets::INVMGR_BANK_MAIN_FIRST_SLOT + 1;
if (bagID == kKeyring || (bagID >= 1 && bagID <= kLastBankBag))
return INT_MAX;
return 0;
}
int __fastcall Script_SetItemSearch(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_Container.SetItemSearch(searchString)");
return 0;
}
char16_t folded[kFoldCap];
const size_t n = Fold(Game::Lua::ToString(L, 1), folded);
// 4.3.4 skips text equal to the current search ignoring case, and an
// empty string while none is set. Equal folded text matches the same
// items, so it is skipped here too.
if (std::u16string_view(folded, n) == g_search)
return 0;
g_search.assign(folded, n);
Event::Custom::Fire(_evt.Slot(), "");
return 0;
}
// True when a search is active and no item in the container matches it —
// so an empty bag, an unknown index and a closed bank all read as filtered
// (the bank's items and its bags' GUIDs are gated on the bank being open).
int __fastcall Script_IsContainerFiltered(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_Container.IsContainerFiltered(containerIndex)");
return 0;
}
if (g_search.empty() || Unit::Identity::PlayerInventoryManager() == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
EquipmentSet::Data::EnsureLoaded();
const int slots = SlotsToWalk(bagID);
Item::Location::BagSlotDetail d;
for (int slot = 1;
slot <= slots && Item::Location::ResolveBagSlotDetail(L, bagID, slot, &d); ++slot) {
if (d.item != nullptr && !Filtered(d.item)) {
Game::Lua::PushBool(L, false);
return 1;
}
}
Game::Lua::PushBool(L, true);
return 1;
}
void ClearOnLogout() { g_search.clear(); }
const Game::GlueModuleAutoRegister _clearOnLogout{&ClearOnLogout};
const Game::Doc::Field kSetItemSearchArgs[] = {
Game::Doc::Req("searchString", "cstring", "An empty string clears the search."),
};
const Game::Doc::Function kSetItemSearch{
"Sets the text that the items in bags and the bank are matched against.",
kSetItemSearchArgs, {}};
const Game::Doc::Field kIsContainerFilteredArgs[] = {
Game::Doc::Req("containerIndex", "BagIndex"),
};
const Game::Doc::Field kIsContainerFilteredRets[] = {
Game::Doc::Req("isFiltered", "bool",
"True when a search is active and no item in the container matches it."),
};
const Game::Doc::Function kIsContainerFiltered{
"Whether the bag search hides every item in a container.", kIsContainerFilteredArgs,
kIsContainerFilteredRets};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "SetItemSearch", &Script_SetItemSearch,
&kSetItemSearch);
Game::Lua::RegisterTableFunction("C_Container", "IsContainerFiltered",
&Script_IsContainerFiltered, &kIsContainerFiltered);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Container::Search
+26
View File
@@ -0,0 +1,26 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
namespace Container::Search {
// 4.3.4's `CGContainerInfo::IsItemFiltered`: true when a bag search is
// active and the item matches none of its name, its equipment sets' names,
// its subclass name or its equip slot's name. False with no search, and for
// an item whose static data isn't cached yet.
bool IsItemFiltered(const uint8_t *cgItem);
} // namespace Container::Search
+2 -4
View File
@@ -118,11 +118,9 @@ int g_mergePackets = 0;
// Item.dbc `m_class` values we rank on. Per-module codes, the way
// `Item::BagFamily` keeps its own.
constexpr uint32_t kClassConsumable = 0;
constexpr uint32_t kClassWeapon = 2;
constexpr uint32_t kClassArmor = 4;
constexpr uint32_t kClassReagent = 5;
constexpr uint32_t kClassTradeGoods = 7;
constexpr uint32_t kClassQuest = 12;
constexpr uint32_t kQualityPoor = 0;
constexpr uint32_t kQualityUncommon = 2;
@@ -149,7 +147,7 @@ uint8_t CategoryFor(uint32_t itemID, uint32_t itemClass, uint32_t quality) {
return kCatHearthstone;
if (quality == kQualityPoor)
return kCatJunk;
if (itemClass == kClassWeapon || itemClass == kClassArmor)
if (itemClass == Offsets::ITEM_CLASS_WEAPON || itemClass == kClassArmor)
return kCatGear;
if (itemClass == kClassConsumable)
return kCatConsumable;
@@ -157,7 +155,7 @@ uint8_t CategoryFor(uint32_t itemID, uint32_t itemClass, uint32_t quality) {
return kCatReagent;
if (itemClass == kClassTradeGoods)
return kCatTradeGoods;
if (itemClass == kClassQuest)
if (itemClass == Offsets::ITEM_CLASS_QUEST)
return kCatQuest;
if (quality >= kQualityEpic)
return kCatOtherEpic;
+14 -2
View File
@@ -49,9 +49,21 @@ int __fastcall Script_C_Container_SwapItems(void *L) {
return 1;
}
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("srcBag", "BagIndex"),
Game::Doc::Req("srcSlot", "luaIndex"),
Game::Doc::Req("dstBag", "BagIndex"),
Game::Doc::Req("dstSlot", "luaIndex"),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Req("sent", "bool", "False when an argument is not valid."),
};
const Game::Doc::Function kSwapItems{
"Swaps the contents of two bag slots without the cursor.", kArgs, kRets, nullptr, true};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "SwapItems",
&Script_C_Container_SwapItems);
Game::Lua::RegisterTableFunction("C_Container", "SwapItems", &Script_C_Container_SwapItems,
&kSwapItems);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+6
View File
@@ -21,6 +21,7 @@
#include "Game.h"
#include "Offsets.h"
#include "profile/Cpu.h"
#include <cstdint>
@@ -95,7 +96,11 @@ static int AuxResume(void *L, void *co, int narg) {
return -1;
}
Game::Lua::XMove(L, co, narg);
Profile::Cpu::OnResume(co);
const int s = Game::Lua::Resume(co, narg);
// The status code can't tell a yield apart (see above); the call stack can.
if (IsYielded(co))
Profile::Cpu::OnYield(co);
if (s == 0 || IsYielded(co)) {
const int nres = Game::Lua::GetTop(co);
if (Game::Lua::CheckStack(L, nres + 1) == 0)
@@ -114,6 +119,7 @@ static int __fastcall Script_Coroutine_Create(void *L) {
}
Game::Lua::NewThread(L); // [arg1, NL]
void *NL = Game::Lua::ToThread(L, -1);
Profile::Cpu::OnThreadCreated(NL); // threads don't inherit the hook
Game::Lua::PushValue(L, 1); // [arg1, NL, fn]
Game::Lua::XMove(L, NL, 1); // [arg1, NL]; NL has fn
return 1;
+75
View File
@@ -11,6 +11,16 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `GetCoinText` / `C_CurrencyInfo.GetCoinText` — the plain-text money string
// ("12 Gold, 34 Silver, 56 Copper"), 4.3.4's `Script_GetCoinText` →
// `GetCurrencyText` (LootFrame.cpp): the non-zero denominations, gold first,
// each from its `GOLD_AMOUNT` / `SILVER_AMOUNT` / `COPPER_AMOUNT` GlobalString,
// joined by the separator (default ", "); zero copper is the empty string.
// 1.12's only money formatter (`GetLootRowName`, 0x004C2550) hard-codes
// "%d %s" over GOLD / SILVER / COPPER with "\n", so it can't carry the
// separator or the localized formats; the strings are backported in
// `locales/*.lua`.
//
// `GetCoinTextureString` / `C_CurrencyInfo.GetCoinTextureString` — the coin-icon
// money string (e.g. "12<gold> 34<silver> 56<copper>"), a C++ backport of the
// retail C function.
@@ -37,6 +47,7 @@
#include "Game.h"
#include <cstdint>
#include <cstdio>
#include <string>
@@ -85,6 +96,54 @@ void AppendDenom(void *L, std::string &out, const char *globalName, const char *
out += piece;
}
// Fallbacks byte-identical to the backported enUS GlobalStrings.
constexpr const char *kGoldTextFallback = "%d Gold";
constexpr const char *kSilverTextFallback = "%d Silver";
constexpr const char *kCopperTextFallback = "%d Copper";
// 4.3.4's GetCurrencyText default separator.
constexpr const char *kDefaultSeparator = ", ";
void AppendText(void *L, std::string &out, const char *separator, const char *globalName,
const char *fallback, int count) {
if (count == 0)
return;
if (!out.empty())
out += separator;
const int top = Game::Lua::GetTop(L);
Game::Lua::PushLocalizedFormatInt(L, globalName, fallback, count);
if (const char *piece = Game::Lua::ToString(L, -1))
out += piece;
Game::Lua::SetTop(L, top);
}
// GetCoinText(amount [, separator]) -> string
int __fastcall Script_GetCoinText(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetCoinText(amount [,separator])");
return 0;
}
// 4.3.4 takes the amount as uint32 (`static_cast<uint32_t>(lua_tonumber)`),
// the width of the money field.
const double amt = Game::Lua::ToNumber(L, 1);
const uint32_t amount = amt <= 0.0 ? 0u
: amt >= 4294967295.0 ? 0xFFFFFFFFu
: static_cast<uint32_t>(amt);
const char *separator =
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : kDefaultSeparator;
std::string out;
AppendText(L, out, separator, "GOLD_AMOUNT", kGoldTextFallback,
static_cast<int>(amount / 10000));
AppendText(L, out, separator, "SILVER_AMOUNT", kSilverTextFallback,
static_cast<int>((amount % 10000) / 100));
AppendText(L, out, separator, "COPPER_AMOUNT", kCopperTextFallback,
static_cast<int>(amount % 100));
Game::Lua::PushString(L, out.c_str());
return 1;
}
// GetCoinTextureString(amount [, fontHeight]) -> string
int __fastcall Script_GetCoinTextureString(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
@@ -127,7 +186,23 @@ int __fastcall Script_GetCoinTextureString(void *L) {
return 1;
}
const Game::Doc::Field kCoinTextArgs[] = {
Game::Doc::Req("amount", "number", "The amount of money, in copper."),
Game::Doc::Opt("separator", "string", "\", \"",
"The text between the gold, silver and copper parts."),
};
const Game::Doc::Field kCoinTextRets[] = {
Game::Doc::Req("result", "string",
"For example \"12 Gold, 34 Silver, 56 Copper\". Empty for 0."),
};
const Game::Doc::Function kGetCoinText{
"Formats an amount of money as text, with a word for each coin.", kCoinTextArgs,
kCoinTextRets, "CurrencyInfo"};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetCoinText", &Script_GetCoinText, &kGetCoinText);
Game::Lua::RegisterTableFunction("C_CurrencyInfo", "GetCoinText", &Script_GetCoinText,
&kGetCoinText);
Game::Lua::RegisterGlobalFunction("GetCoinTextureString", &Script_GetCoinTextureString);
Game::Lua::RegisterTableFunction("C_CurrencyInfo", "GetCoinTextureString",
&Script_GetCoinTextureString);
+16
View File
@@ -282,8 +282,24 @@ int __fastcall Script_GetCursorInfo(void *L) {
}
}
// `CursorHasMacro()` — 4.3.4's Script_CursorHasMacro (0x008C6F70) tests its
// macro cursor type, 7; 1.12's macro type is 8.
int __fastcall Script_CursorHasMacro(void *L) {
Game::Lua::PushBool(L, ReadVar(Offsets::VAR_CURSOR_TYPE) == TYPE_MACRO);
return 1;
}
const Game::Doc::Field kCursorHasMacroRets[] = {
Game::Doc::Req("hasMacro", "boolean", "True if the cursor holds a macro."),
};
const Game::Doc::Function kCursorHasMacro{
"Returns true if the cursor holds a macro.",
{}, kCursorHasMacroRets, "CursorGlobals"};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetCursorInfo", &Script_GetCursorInfo);
Game::Lua::RegisterGlobalFunction("CursorHasMacro", &Script_CursorHasMacro,
&kCursorHasMacro);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+218
View File
@@ -0,0 +1,218 @@
// 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_DeathInfo.GetSelfResurrectOptions()`, `C_DeathInfo.UseSelfResurrectOption
// (optionType, id)` and `SELF_RES_SPELL_CHANGED` — the self-resurrection that
// 1.12 serves through HasSoulstone (0x0048AC80) and UseSoulstone (0x0048AD70).
//
// 1.12 offers at most one way for a dead player to raise themselves, and both
// functions pick it the same way: PLAYER_SELF_RES_SPELL when the server set it
// (Soulstone Resurrection, Reincarnation), else the first carried item whose
// on-use spell is a self-resurrect. So the list holds that one option, under
// HasSoulstone's gate (a living player has none), and using it calls
// UseSoulstone itself. Nothing in 1.12 limits a self-resurrection, so every
// option can be used and none is limited.
//
// SELF_RES_SPELL_CHANGED is 4.3.4's: CGPlayer_C::SetActiveMirrorHandlers
// watches PLAYER_SELF_RES_SPELL with OnUpdatePlayerScriptEvent. 1.12 watches
// no such field, so the same watcher joins 1.12's AddActiveWatchers and
// leaves with its removal, the lifetime of the engine's own player watchers.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "item/Link.h"
#include "spell/Lookup.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Death::SelfResurrect {
namespace {
constexpr int OPTION_TYPE_SPELL = 0;
constexpr int OPTION_TYPE_ITEM = 1;
const Game::Doc::Event kChangedDoc{
"DeathInfo",
"The player's self-resurrection spell changed. Read C_DeathInfo.GetSelfResurrectOptions.",
{}};
const Event::Custom::AutoReserve kChanged{"SELF_RES_SPELL_CHANGED", &kChangedDoc};
struct Option {
const char *name;
int optionType;
int id;
};
// HasSoulstone's answer: the option, or false while the player lives or has
// none. `itemName` holds an item option's name.
bool CurrentOption(Option &out, char *itemName, size_t itemNameSize) {
const uint8_t *player = Unit::Identity::PlayerObject();
const uint8_t *desc = Unit::Identity::PlayerDescriptor();
if (player == nullptr || desc == nullptr ||
Game::Read<int32_t>(desc, Offsets::OFF_UNIT_FIELD_HEALTH) > 0)
return false;
const auto *info = Game::Read<const uint8_t *>(player, Offsets::OFF_CGPLAYER_INFO);
const int spellID =
info != nullptr ? Game::Read<int32_t>(info, Offsets::OFF_CGPLAYER_INFO_SELF_RES_SPELL) : 0;
if (spellID != 0) {
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
out = {rec != nullptr
? Game::Read<const char *>(rec, Offsets::OFF_SPELL_NAMES +
Game::Read<int>(Offsets::VAR_LOCALE_INDEX) *
4)
: "UNKNOWN",
OPTION_TYPE_SPELL, spellID};
return true;
}
using SelfResItem_t = const uint8_t *(__fastcall *)(const void *player);
const uint8_t *item =
reinterpret_cast<SelfResItem_t>(Offsets::FUN_PLAYER_SELF_RES_ITEM)(player);
if (item == nullptr)
return false;
Item::Link::NameFromCGItem(item, itemName, itemNameSize);
const auto *fields = Game::Read<const uint8_t *>(item, Offsets::OFF_UNIT_GUID_PTR);
out = {itemName, OPTION_TYPE_ITEM,
fields != nullptr ? Game::Read<int32_t>(fields, Offsets::OFF_OBJECT_FIELD_ENTRY) : 0};
return true;
}
int __fastcall Script_GetSelfResurrectOptions(void *L) {
if (Unit::Identity::PlayerObject() == nullptr)
return 0;
Game::Lua::NewTable(L);
char itemName[0x400];
Option option;
if (CurrentOption(option, itemName, sizeof(itemName))) {
Game::Lua::PushNumber(L, 1);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", option.name);
Game::Lua::SetFieldNumber(L, "optionType", option.optionType);
Game::Lua::SetFieldNumber(L, "id", option.id);
Game::Lua::SetFieldBool(L, "canUse", true);
Game::Lua::SetFieldBool(L, "isLimited", false);
Game::Lua::SetFieldNumber(L, "priority", 0);
Game::Lua::SetTable(L, -3);
}
return 1;
}
int __fastcall Script_UseSelfResurrectOption(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: C_DeathInfo.UseSelfResurrectOption(optionType, id)");
return 0;
}
char itemName[0x400];
Option option;
if (CurrentOption(option, itemName, sizeof(itemName)) &&
option.optionType == static_cast<int>(Game::Lua::ToNumber(L, 1)) &&
option.id == static_cast<int>(Game::Lua::ToNumber(L, 2)))
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_SCRIPT_USE_SOULSTONE)(L);
return 0;
}
// Observer callback ABI per the FUN_DESC_OBSERVER_REGISTER block in
// Offsets.h: __fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi,
// oldValues*, userArg), returns 1, callee cleans 0x10. It runs only when the
// field's bytes changed.
int __fastcall SelfResSpellChanged(uint32_t /*fieldOffset*/, uint32_t /*size*/,
uint32_t /*guidLo*/, uint32_t /*guidHi*/,
const void * /*oldValues*/, void * /*userArg*/) {
Event::Custom::Fire(kChanged.Slot(), "");
return 1;
}
using Register_t = void(__fastcall *)(int bank, uint32_t fieldOffset, uint32_t guidLo,
uint32_t guidHi, int size, const void *callback,
void *userArg1, void *userArg2);
using Unregister_t = void(__fastcall *)(int bank, uint32_t fieldOffset, uint32_t guidLo,
uint32_t guidHi, const void *callback, void *userArg1);
using Watchers_t = void(__fastcall *)(void *player, void *edx);
Watchers_t g_origAddWatchers = nullptr;
Watchers_t g_origRemoveWatchers = nullptr;
void __fastcall AddActiveWatchers_h(void *player, void *edx) {
g_origAddWatchers(player, edx);
const uint64_t guid = Unit::Identity::GuidForObject(player);
reinterpret_cast<Register_t>(Offsets::FUN_DESC_OBSERVER_REGISTER)(
Offsets::DESC_OBSERVER_BANK_PLAYER, Offsets::OFF_CGPLAYER_INFO_SELF_RES_SPELL,
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32), 4,
reinterpret_cast<const void *>(&SelfResSpellChanged), nullptr, nullptr);
}
// Removed before the original, while the registry is as the engine's own
// removals find it.
void __fastcall RemoveActiveWatchers_h(void *player, void *edx) {
const uint64_t guid = Unit::Identity::GuidForObject(player);
reinterpret_cast<Unregister_t>(Offsets::FUN_DESC_OBSERVER_UNREGISTER)(
Offsets::DESC_OBSERVER_BANK_PLAYER, Offsets::OFF_CGPLAYER_INFO_SELF_RES_SPELL,
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
reinterpret_cast<const void *>(&SelfResSpellChanged), nullptr);
g_origRemoveWatchers(player, edx);
}
const Game::HookAutoRegister _addHook{Offsets::FUN_PLAYER_ADD_ACTIVE_WATCHERS,
reinterpret_cast<void *>(&AddActiveWatchers_h),
reinterpret_cast<void **>(&g_origAddWatchers)};
const Game::HookAutoRegister _removeHook{Offsets::FUN_PLAYER_REMOVE_ACTIVE_WATCHERS,
reinterpret_cast<void *>(&RemoveActiveWatchers_h),
reinterpret_cast<void **>(&g_origRemoveWatchers)};
const Game::Lua::EnumIntegerEntry kOptionTypeEntries[] = {
{"Spell", OPTION_TYPE_SPELL},
{"Item", OPTION_TYPE_ITEM},
};
const Game::Doc::Field kOptionFields[] = {
Game::Doc::Req("name", "string"),
Game::Doc::Req("optionType", "SelfResurrectOptionType"),
Game::Doc::Req("id", "number", "The spell ID or item ID."),
Game::Doc::Req("canUse", "bool"),
Game::Doc::Req("isLimited", "bool"),
Game::Doc::Req("priority", "number", "Lower comes first."),
};
const Game::Doc::Structure kOption{"SelfResurrectOption", "DeathInfo", kOptionFields,
"A way for the dead player to resurrect themselves."};
const Game::Doc::Field kGetRets[] = {
Game::Doc::Req("options", "table", "Array of SelfResurrectOption; empty while alive."),
};
const Game::Doc::Function kGetSelfResurrectOptions{
"The ways the dead player can resurrect themselves.", {}, kGetRets};
const Game::Doc::Field kUseArgs[] = {
Game::Doc::Req("optionType", "SelfResurrectOptionType"),
Game::Doc::Req("id", "number"),
};
const Game::Doc::Function kUseSelfResurrectOption{
"Resurrects the dead player with one of the options from GetSelfResurrectOptions.",
kUseArgs, {}};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_DeathInfo", "GetSelfResurrectOptions",
&Script_GetSelfResurrectOptions, &kGetSelfResurrectOptions);
Game::Lua::RegisterTableFunction("C_DeathInfo", "UseSelfResurrectOption",
&Script_UseSelfResurrectOption, &kUseSelfResurrectOption);
Game::Lua::RegisterIntegerEnum("Enum", "SelfResurrectOptionType", kOptionTypeEntries,
sizeof(kOptionTypeEntries) / sizeof(kOptionTypeEntries[0]),
"DeathInfo");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Death::SelfResurrect
+1 -2
View File
@@ -86,7 +86,6 @@ const Game::HookAutoRegister _countHook{Offsets::FUN_EDITBOX_COUNT_CHARS, reinte
using Load_t = uintptr_t(__thiscall *)(void *self, const void *node, void *status);
using GetAttr_t = const char *(__thiscall *)(const void *node, const char *name);
using AttrToBool_t = int(__fastcall *)(const char *text);
Load_t g_origEditBoxLoad = nullptr;
// `self` is the loader sub-object inside the edit box.
@@ -99,7 +98,7 @@ uintptr_t __fastcall EditBoxLoad_h(void *self, void * /*edx*/, const void *node,
if (value == nullptr || *value == '\0')
return result;
void *editBox = static_cast<uint8_t *>(self) - Offsets::OFF_XML_LOADER_SUBOBJECT;
if (reinterpret_cast<AttrToBool_t>(Offsets::FUN_XML_ATTR_TO_BOOL)(value))
if (Game::StringToFlag(value))
g_counting.insert(editBox);
else
g_counting.erase(editBox);
+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/>.
// Retail's input history: an added line always goes to the end of the ring,
// and the next Up starts again from that newest line.
//
// The engine's ring keeps ONE position, OFF_EDITBOX_HISTORY_POS, for two jobs:
// AddHistoryLine writes there and advances it, and the Up/Down walkers move it
// to the line they show (benilla-re ui/editbox "History"; 3.3.5's
// CSimpleEditBox::AddHistoryLine is the same). So a line sent after browsing
// overwrites the line the browsing reached, and the sends after it overwrite
// the newer lines in turn. Probe, chat box: send /run print(1) .. print(4),
// Up twice, Escape, send print(5), then walk Up — 1.12 shows 5, 2, 1 (3 and 4
// are gone), retail shows 5, 4, 3, 2, 1 (2026-10-26).
//
// This keeps each box's write position beside the engine's field. The add
// puts it back first, so the line lands at the end, and the advanced position
// it leaves is where the next Up starts. The walkers are untouched: the
// engine's field is their browse position. The resize does the same, so the
// engine's own clamp applies to the write position, and its size-0 call from
// the destructor drops the box before its address can be reused.
//
// A line equal to the newest one is not added again, as on retail (sending
// print(5) twice keeps one); the browse position still goes back to the end.
// Only the newest line is compared, case-sensitively: retail keeps older
// copies (print(1), print(2), print(1) walks 1, 2, 1) and adds /RUN print(5)
// after /run print(5) (2026-10-26).
#include "Game.h"
#include "Offsets.h"
#include <cstring>
#include <unordered_map>
namespace EditBox::History {
namespace {
using AddHistoryLine_t = void(__fastcall *)(void *editBox, void *edx, const char *text, int exec);
using SetHistoryMax_t = void(__fastcall *)(void *editBox, void *edx, int maxLines);
AddHistoryLine_t g_origAddHistoryLine = nullptr;
SetHistoryMax_t g_origSetHistoryMax = nullptr;
struct HistoryRecord {
const char *text;
int exec;
};
// The write position of every box with a history ring.
std::unordered_map<void *, int> g_writePos;
void RestoreWritePos(void *editBox) {
const auto it = g_writePos.find(editBox);
if (it != g_writePos.end())
Game::Ref<int>(editBox, Offsets::OFF_EDITBOX_HISTORY_POS) = it->second;
}
void RecordWritePos(void *editBox) {
if (Game::Read<int>(editBox, Offsets::OFF_EDITBOX_HISTORY_MAX) > 0)
g_writePos[editBox] = Game::Read<int>(editBox, Offsets::OFF_EDITBOX_HISTORY_POS);
else
g_writePos.erase(editBox);
}
// Whether text equals the line just before the write position.
bool IsNewest(void *editBox, const char *text) {
const int max = Game::Read<int>(editBox, Offsets::OFF_EDITBOX_HISTORY_MAX);
const auto *records = Game::Read<const HistoryRecord *>(editBox, Offsets::OFF_EDITBOX_HISTORY_DATA);
if (max <= 0 || records == nullptr || text == nullptr)
return false;
const int newest = (Game::Read<int>(editBox, Offsets::OFF_EDITBOX_HISTORY_POS) + max - 1) % max;
return records[newest].text != nullptr && std::strcmp(records[newest].text, text) == 0;
}
void __fastcall AddHistoryLine_h(void *editBox, void *edx, const char *text, int exec) {
RestoreWritePos(editBox);
if (IsNewest(editBox, text))
return;
g_origAddHistoryLine(editBox, edx, text, exec);
RecordWritePos(editBox);
}
void __fastcall SetHistoryMax_h(void *editBox, void *edx, int maxLines) {
RestoreWritePos(editBox);
g_origSetHistoryMax(editBox, edx, maxLines);
RecordWritePos(editBox);
}
const Game::HookAutoRegister _addHook{Offsets::FUN_EDITBOX_ADD_HISTORY_LINE,
reinterpret_cast<void *>(&AddHistoryLine_h),
reinterpret_cast<void **>(&g_origAddHistoryLine)};
const Game::HookAutoRegister _setMaxHook{Offsets::FUN_EDITBOX_SET_HISTORY_MAX,
reinterpret_cast<void *>(&SetHistoryMax_h),
reinterpret_cast<void **>(&g_origSetHistoryMax)};
} // namespace
} // namespace EditBox::History
+308
View File
@@ -0,0 +1,308 @@
// 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/>.
// Tall inline icons move the edit box's rows.
//
// A multi-line edit box places its caret, its selection pieces and a click's
// cursor by rows of one height — the label's font height plus its spacing,
// pixel-aligned (FUN_EDITBOX_PLACE_CARET, FUN_EDITBOX_PLACE_SELECTION_PIECE,
// FUN_EDITBOX_INDEX_AT_POINT; 4.3.4's UpdateVisibleCursor, UpdateHighlightArea
// and ConvertCoordinateToIndex step the same rows). The label's text leaves
// those rows once a line holds an icon taller than the font: the render moves
// that line and every line after it down by the overflow (4.3.4's
// InitializeText; the emitter co-hook in Text::InlineTexture), and the box
// grows by the same amount through the string-height co-hook. So below the
// first tall-icon line the caret and the highlight sat above their text by
// the summed overflow, and OnCursorChanged reported that y, which
// ScrollingEditBoxMixin:ScrollCursorIntoView scrolls by — pfUI's chat copy
// box with faction-emblem icons. 4.3.4 has the same fault; nothing it ships
// puts a tall icon in an edit box.
//
// Each placer keeps its engine code and gets the move the render makes, from
// Text::InlineTexture::LineIconShift over the box's own line table (the
// breaks the render lays out), split by the label's vertical justify as the
// emitter splits it:
// - the click: its y is moved onto the uniform row that the icon-aware row
// under it has, before the engine maps it;
// - a selection piece: re-anchored after the engine placed it, with the
// engine's own x, lower by the move;
// - the caret: the same re-anchor, then OnCursorChanged fired once with the
// moved y through the engine's own forwarder. The engine's fire is muted
// for the call by emptying the script slot: it reads the row y before
// anything can correct it.
// A single-line box has one row and needs nothing: its label is justified
// MIDDLE in a rect of fixed height, so its text stays centred on the caret.
#include "Game.h"
#include "Offsets.h"
#include "text/InlineTexture.h"
#include <cstdint>
namespace EditBox::IconRows {
namespace {
using PlaceCaret_t = void(__fastcall *)(void *editBox);
using PlaceSelectionPiece_t = void(__fastcall *)(void *editBox, void *edx, void *piece, int from, int to);
using IndexAtPoint_t = void(__fastcall *)(void *editBox, void *edx, float x, float y, int *index);
using FontHeight_t = float(__fastcall *)(void *fs, void *edx, int mode);
using MeasureRun_t = float(__fastcall *)(void *fs, void *edx, const uint8_t *text, int len);
using PixelAlignY_t = float(__stdcall *)(float length);
using GetRect_t = int(__fastcall *)(void *anchor, void *edx, float *out);
using ClearAllPoints_t = void(__fastcall *)(void *anchor);
using SetPoint_t = void(__thiscall *)(void *anchor, int point, void *relAnchor, int relPoint, float x, float y,
int flag);
using Resize_t = void(__fastcall *)(void *anchor, void *edx, int force);
using Extent_t = float(__fastcall *)(void *anchor, void *edx);
using ScreenAspect_t = float(__cdecl *)();
using DivideByScreenScaleX_t = float(__stdcall *)(float value);
using FireVariadic_t = void(__cdecl *)(void *frame, uint32_t *slotPtr, const char *fmt, ...);
PlaceCaret_t g_origPlaceCaret = nullptr;
PlaceSelectionPiece_t g_origPlaceSelectionPiece = nullptr;
IndexAtPoint_t g_origIndexAtPoint = nullptr;
void *AnchorOf(void *region) { return static_cast<uint8_t *>(region) + Offsets::OFF_REGION_ANCHOR; }
// A multi-line box's rows: its label, the buffer the line table indexes (the
// '*' copy of a password box) and the table — each line's first byte, the
// last entry the end.
struct Rows {
void *fs = nullptr;
const uint8_t *text = nullptr;
const uint32_t *starts = nullptr;
int count = 0; // entries; lines = count - 1
float scale = 1.0f; // the box's OFF_LAYOUT_SCALE
};
bool MultiLineRows(void *editBox, Rows &rows) {
const uint8_t mode = Game::Read<uint8_t>(editBox, Offsets::OFF_EDITBOX_MODE_FLAGS);
if ((mode & Offsets::EDITBOX_MODE_MULTILINE) == 0)
return false;
rows.fs = Game::Read<void *>(editBox, Offsets::OFF_EDITBOX_FONTSTRING);
rows.text = Game::Read<const uint8_t *>(editBox, (mode & Offsets::EDITBOX_MODE_PASSWORD)
? Offsets::OFF_EDITBOX_TEXT_MASKED
: Offsets::OFF_EDITBOX_TEXT);
rows.starts = Game::Read<const uint32_t *>(editBox, Offsets::OFF_EDITBOX_LINE_STARTS);
rows.count = Game::Read<int>(editBox, Offsets::OFF_EDITBOX_LINE_COUNT);
rows.scale = Game::Read<float>(editBox, Offsets::OFF_LAYOUT_SCALE);
return rows.fs != nullptr && rows.text != nullptr && rows.starts != nullptr && rows.count >= 2 &&
rows.scale > 0.0f;
}
// The line holding byte `pos` — the placers' walk down the table.
int LineOf(const Rows &rows, uint32_t pos) {
int line = 0;
while (pos >= rows.starts[line + 1]) {
if (line >= rows.count - 2)
break;
++line;
}
return line;
}
bool LineShift(const Rows &rows, int line, float &before, float &after) {
before = 0.0f;
after = 0.0f;
const uint32_t start = rows.starts[line];
const uint32_t end = rows.starts[line + 1];
return end > start &&
Text::InlineTexture::LineIconShift(rows.fs, rows.text + start, static_cast<int>(end - start), before, after);
}
// How far the render moves line `line`'s text below its uniform row: the
// moves of every line above it, and its own text's move.
float ShiftOf(const Rows &rows, int line) {
float shift = 0.0f;
for (int i = 0; i <= line; ++i) {
float before = 0.0f;
float after = 0.0f;
if (!LineShift(rows, i, before, after))
continue;
shift += before;
if (i < line)
shift += after;
}
return shift;
}
// The placers' row in the box's parent units: the label's font height and
// spacing, each pixel-aligned at the box's scale (PlaceCaret
// 0x0077DAEB..0x0077DB0E).
float RowHeight(const Rows &rows) {
const float fontH = reinterpret_cast<FontHeight_t>(Offsets::FUN_FONTSTRING_FONT_HEIGHT)(rows.fs, nullptr, 1);
const float spacing = Game::Read<float>(rows.fs, Offsets::OFF_FONTSTRING_SPACING);
const auto align = reinterpret_cast<PixelAlignY_t>(Offsets::FUN_PIXEL_ALIGN_Y);
return align(fontH * rows.scale) + align(spacing * rows.scale);
}
float MeasureRun(const Rows &rows, uint32_t from, uint32_t to) {
return reinterpret_cast<MeasureRun_t>(Offsets::FUN_FONTSTRING_MEASURE_SUBSTRING)(rows.fs, nullptr, rows.text + from,
static_cast<int>(to - from));
}
// The x the placers anchor byte `pos` of `line` at, and the label point they
// anchor to, by the label's horizontal justify (OFF_FONTSTRING_FLAGS & 7).
// False for none, which anchors nothing.
bool AnchorX(const Rows &rows, int line, uint32_t pos, float &x, int &relPoint) {
const uint32_t start = rows.starts[line];
const uint32_t end = rows.starts[line + 1];
switch (Game::Read<uint32_t>(rows.fs, Offsets::OFF_FONTSTRING_FLAGS) & 7u) {
case 1: // LEFT
x = (pos == start) ? 0.0f : MeasureRun(rows, start, pos);
relPoint = Offsets::FRAMEPOINT_TOPLEFT;
return true;
case 2: // CENTER
x = (pos == start) ? 0.0f : MeasureRun(rows, start, pos);
x -= MeasureRun(rows, start, end) * 0.5f;
relPoint = Offsets::FRAMEPOINT_TOP;
return true;
case 4: // RIGHT
x = (pos == end) ? 0.0f : -MeasureRun(rows, pos, end);
relPoint = Offsets::FRAMEPOINT_TOPRIGHT;
return true;
default:
return false;
}
}
// Anchors `region` as the placer did, at `y`.
void Anchor(const Rows &rows, void *region, int relPoint, float x, float y, int flag) {
void *anchor = AnchorOf(region);
reinterpret_cast<ClearAllPoints_t>(Offsets::FUN_LAYOUT_CLEAR_ALL_POINTS)(anchor);
reinterpret_cast<SetPoint_t>(Offsets::FUN_REGION_SET_POINT)(anchor, Offsets::FRAMEPOINT_TOPLEFT, AnchorOf(rows.fs),
relPoint, x, y, flag);
}
// --- the click ---------------------------------------------------------------
void __fastcall IndexAtPoint_h(void *editBox, void *edx, float x, float y, int *index) {
Rows rows;
if (!MultiLineRows(editBox, rows))
return g_origIndexAtPoint(editBox, edx, x, y, index);
float rect[4] = {0.0f, 0.0f, 0.0f, 0.0f}; // bottom, left, top, right
if (reinterpret_cast<GetRect_t>(Offsets::FUN_REGION_GET_RECT)(AnchorOf(rows.fs), nullptr, rect) == 0)
return g_origIndexAtPoint(editBox, edx, x, y, index);
// The engine walks rows down from the label's top while the click is
// below the row (strictly), stopping at the last line. Walk the rows as
// the render lays them out: each taller by its line's moves.
const float row = RowHeight(rows);
const float below = rect[2] - y;
int line = 0;
float top = 0.0f; // the icon-aware top of `line`, below the label's top
bool moved = false;
for (;;) {
float before = 0.0f;
float after = 0.0f;
if (LineShift(rows, line, before, after))
moved = true;
const float height = row + (before + after) * rows.scale;
if (!(below > top + height) || line >= rows.count - 2)
break;
top += height;
++line;
}
// Without a move the rows are the engine's own; with one, the click is
// moved onto the middle of the uniform row the engine maps to `line`.
const float yMapped = moved ? rect[2] - (static_cast<float>(line) + 0.5f) * row : y;
g_origIndexAtPoint(editBox, edx, x, yMapped, index);
}
// --- a selection piece --------------------------------------------------------
void __fastcall PlaceSelectionPiece_h(void *editBox, void *edx, void *piece, int from, int to) {
Rows rows;
if (!MultiLineRows(editBox, rows) || from < 0)
return g_origPlaceSelectionPiece(editBox, edx, piece, from, to);
const int line = LineOf(rows, static_cast<uint32_t>(from));
const float shift = ShiftOf(rows, line);
g_origPlaceSelectionPiece(editBox, edx, piece, from, to);
if (!(shift > 0.0f) || piece == nullptr)
return;
// The piece starts at `from`, clamped to its line.
uint32_t pos = static_cast<uint32_t>(from);
if (pos < rows.starts[line])
pos = rows.starts[line];
float x = 0.0f;
int relPoint = 0;
if (!AnchorX(rows, line, pos, x, relPoint))
return;
const float y = -(RowHeight(rows) / rows.scale) * static_cast<float>(line) - shift;
Anchor(rows, piece, relPoint, x, y, 0);
reinterpret_cast<Resize_t>(Offsets::FUN_LAYOUT_RESIZE)(AnchorOf(piece), nullptr, 1);
}
// --- the caret ----------------------------------------------------------------
void __fastcall PlaceCaret_h(void *editBox) {
Rows rows;
if (!MultiLineRows(editBox, rows))
return g_origPlaceCaret(editBox);
const int cursor = Game::Read<int>(editBox, Offsets::OFF_EDITBOX_CURSOR_BYTE);
const int visibleStart = Game::Read<int>(editBox, Offsets::OFF_EDITBOX_VISIBLE_START);
const int visibleLength = Game::Read<int>(editBox, Offsets::OFF_EDITBOX_VISIBLE_LENGTH);
// Outside the window the engine hides the caret and fires nothing.
if (cursor < visibleStart || cursor > visibleStart + visibleLength)
return g_origPlaceCaret(editBox);
const int line = LineOf(rows, static_cast<uint32_t>(cursor));
const float shift = ShiftOf(rows, line);
if (!(shift > 0.0f))
return g_origPlaceCaret(editBox);
uint32_t *slot = Game::Ptr<uint32_t>(editBox, Offsets::OFF_EDITBOX_ONCURSORCHANGED_SLOT);
const uint32_t handler = slot[0];
slot[0] = 0;
g_origPlaceCaret(editBox);
slot[0] = handler;
void *caret = Game::Read<void *>(editBox, Offsets::OFF_EDITBOX_CARET);
if (caret == nullptr)
return;
const float y = -(RowHeight(rows) / rows.scale) * static_cast<float>(line) - shift;
float x = 0.0f;
int relPoint = 0;
if (AnchorX(rows, line, static_cast<uint32_t>(cursor), x, relPoint))
Anchor(rows, caret, relPoint, x, y, 1);
if (handler == 0)
return;
// OnCursorChanged(x, y, width, height) as the engine fires it: each
// value by the anchor→script factor, pushed as a double.
void *anchor = AnchorOf(caret);
void **vtable = *static_cast<void ***>(anchor);
const float width = reinterpret_cast<Extent_t>(vtable[7])(anchor, nullptr); // +0x1C GetWidth
const float height = reinterpret_cast<Extent_t>(vtable[8])(anchor, nullptr); // +0x20 GetHeight
const auto aspect = reinterpret_cast<ScreenAspect_t>(Offsets::FUN_COORD_GET_SCALED_SCREEN_ASPECT);
const auto divide = reinterpret_cast<DivideByScreenScaleX_t>(Offsets::FUN_COORD_DIVIDE_BY_SCREEN_SCALE_X);
const float factor = Game::Read<float>(Offsets::FLOAT_SCRIPT_COORD_SCALE);
const auto push = [&](float value) { return static_cast<double>(divide(aspect() * factor * value)); };
const double args[4] = {push(x), push(y), push(width), push(height)};
void *L = Game::Lua::State();
const int savedTop = (L != nullptr) ? Game::Lua::GetTop(L) : 0;
reinterpret_cast<FireVariadic_t>(Offsets::FUN_FRAME_RUN_SCRIPT_VARIADIC)(
editBox, slot, reinterpret_cast<const char *>(Offsets::VAR_SCRIPT_FMT_FFFF), args[0], args[1], args[2],
args[3]);
if (L != nullptr)
Game::Lua::SetTop(L, savedTop);
}
const Game::HookAutoRegister _caretHook{Offsets::FUN_EDITBOX_PLACE_CARET, reinterpret_cast<void *>(&PlaceCaret_h),
reinterpret_cast<void **>(&g_origPlaceCaret)};
const Game::HookAutoRegister _pieceHook{Offsets::FUN_EDITBOX_PLACE_SELECTION_PIECE,
reinterpret_cast<void *>(&PlaceSelectionPiece_h),
reinterpret_cast<void **>(&g_origPlaceSelectionPiece)};
const Game::HookAutoRegister _clickHook{Offsets::FUN_EDITBOX_INDEX_AT_POINT,
reinterpret_cast<void *>(&IndexAtPoint_h),
reinterpret_cast<void **>(&g_origIndexAtPoint)};
} // namespace
} // namespace EditBox::IconRows
+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/>.
// `Enum.InputContext` — retail's InputConstants enumeration: the input an
// edit box's OnCursorChanged names as its `context`. ScrollingEditBox
// (Blizzard_SharedXML) compares it with Enum.InputContext.Keyboard.
#include "Game.h"
namespace EditBox::InputContext {
namespace {
const Game::Lua::EnumIntegerEntry kEntries[] = {
{"None", 0},
{"Keyboard", 1},
{"Mouse", 2},
{"GamePad", 3},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum("Enum", "InputContext", kEntries,
sizeof(kEntries) / sizeof(kEntries[0]), "InputConstants");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace EditBox::InputContext
@@ -0,0 +1,142 @@
## Interface: 11200
## Title: !!!ClassicAPI
## Notes: Library to emulate aspects of the modern WoW API on 1.12.1 / Lua 5.0.
## Version: DEV
## Author: Brues
## X-Website: https://github.com/brues-code/ClassicAPI
locales\locales.xml
FrameXML\InterfaceOptions.xml
FrameXML\InterfaceOptions.lua
SecureTemplates.xml
Compat.lua
Util\EnvironmentUtil.lua
Util\ErrorUtil.lua
Util\Print.lua
Util\Mixin.lua
Util\Color.lua
Util\Constants.lua
Util\SoundKitConstants.lua
Util\TableUtil.lua
Util\EnumUtil.lua
Util\StringUtil.lua
Util\MathUtil.lua
Util\PixelUtil.lua
Util\FrameUtil.lua
Util\InputUtil.lua
Util\FormattingUtil.lua
Util\TimeUtil.lua
Util\Flags.lua
Util\ButtonStateBehavior.lua
Util\CallbackRegistry.lua
Util\GlobalCallbackRegistry.lua
Util\TemplateInfoCache.lua
Util\FrameFactory.lua
Util\DataProvider.lua
Util\TreeListDataProvider.lua
Util\CircularBuffer.lua
Util\ColorUtil.lua
Util\ItemLocation.lua
Util\PlayerLocation.lua
Util\EventUtil.lua
Util\ItemUtil.lua
Util\AddOnCompat.lua
Util\FunctionUtil.lua
Util\CacheUtil.lua
Util\Pools.lua
Util\SecureTypes.lua
Util\ProxyUtil.lua
Util\FrameWatcher.lua
Util\TimedCallback.lua
Util\EasingUtil.lua
Util\Interpolator.lua
Util\AnchorUtil.lua
Util\RegionUtil.lua
Util\GridLayoutUtil.lua
Util\EquipmentManager.lua
Util\PlayerUtil.lua
Util\SlashCommandsRegistry.lua
Util\LinkUtil.lua
Util\TradeSkillLink.lua
Util\IconDataProvider.lua
Util\GameTooltip.lua
Util\Rectangle.lua
Util\Vector2D.lua
Util\Vector3D.lua
Util\Vector4D.lua
Util\UiMapPoint.lua
Util\UIParent.lua
Util\ActionButton.lua
Util\Cooldown.lua
Util\WorldMap.lua
Util\AuraDurationModifiers.lua
Util\AuraUtil.lua
Util\SecureUnitMenu.lua
Util\TextureUtil.lua
Util\MacroOptions.lua
Util\SecureStateDriver.lua
Util\MacroIconSelection.lua
FrameXML\Backdrop.lua
FrameXML\Backdrop.xml
FrameXML\EventFrame.lua
FrameXML\EventFrame.xml
FrameXML\EventButton.lua
FrameXML\EventButton.xml
FrameXML\EventEditBox.lua
FrameXML\EventEditBox.xml
FrameXML\EventScrollFrame.lua
FrameXML\EventScrollFrame.xml
FrameXML\Scroll\ScrollUtil.lua
FrameXML\Scroll\ScrollController.lua
FrameXML\Scroll\ScrollBoxPadding.lua
FrameXML\Scroll\ScrollBoxViewUtil.lua
FrameXML\Scroll\ScrollBoxStride.lua
FrameXML\Scroll\ScrollBoxView.lua
FrameXML\Scroll\ScrollBoxListView.lua
FrameXML\Scroll\ScrollBoxLinearView.lua
FrameXML\Scroll\ScrollBoxBiaxalView.lua
FrameXML\Scroll\ScrollBoxTreeView.lua
FrameXML\Scroll\ScrollBoxSequenceView.lua
FrameXML\Scroll\ScrollBoxGridView.lua
FrameXML\Scroll\ScrollBox.lua
FrameXML\Scroll\ScrollBox.xml
FrameXML\Scroll\ScrollBar.lua
FrameXML\Scroll\ScrollBar.xml
FrameXML\Scroll\ScrollTemplates.lua
FrameXML\Scroll\ScrollTemplates.xml
FrameXML\Scroll\TrimScrollBar.lua
FrameXML\Scroll\TrimScrollBar.xml
FrameXML\LayoutFrame.lua
FrameXML\LayoutFrame.xml
FrameXML\SharedUIPanelTemplates.xml
FrameXML\ColorSwatch.lua
FrameXML\ColorSwatch.xml
Menu\MenuConstants.lua
Menu\Classic\MenuConstants.lua
Menu\MenuVariants.lua
Menu\Vanilla\MenuVariants.lua
Menu\Compositor.lua
Menu\Menu.lua
Menu\Menu.xml
Menu\DropdownButton.lua
Menu\DropdownButton.xml
Menu\MenuTemplates.lua
Menu\MenuTemplates.xml
Menu\Classic\MenuTemplates.lua
Menu\Classic\MenuTemplates.xml
Menu\MenuUtil.lua
Options\Panel.lua
APIDocumentation\BaseAPIMixin.lua
APIDocumentation\FieldsAPIMixin.lua
APIDocumentation\FunctionsAPIMixin.lua
APIDocumentation\SystemsAPIMixin.lua
APIDocumentation\TablesAPIMixin.lua
APIDocumentation\EventsAPIMixin.lua
APIDocumentation\APIDocumentation.lua
APIDocumentation\APIDocumentation_Bootstrap.lua
@@ -0,0 +1,225 @@
-- Backport of retail's Blizzard_SharedXML/Backdrop.lua. The BACKDROP_*
-- tables are verbatim. Retail builds the backdrop out of nine textures of its
-- own because its frames lost the C backdrop; 1.12's frames still have it, and
-- it draws the same nine slices, so the mixin keeps retail's methods and state
-- (backdropInfo, HasBackdropInfo, the OnLoad key values) and hands the drawing
-- to the engine. The engine re-slices the edges on every resize, so retail's
-- OnBackdropSizeChanged has nothing to do here. GetBackdrop and the colour
-- methods are the engine's own.
BACKDROP_ACHIEVEMENTS_0_64 = {
edgeFile = "Interface\\AchievementFrame\\UI-Achievement-WoodBorder",
edgeSize = 64,
tileEdge = true,
};
BACKDROP_ARENA_32_32 = {
bgFile = "Interface\\CharacterFrame\\UI-Party-Background",
edgeFile = "Interface\\ArenaEnemyFrame\\UI-Arena-Border",
tile = true,
tileEdge = true,
tileSize = 32,
edgeSize = 32,
insets = { left = 32, right = 32, top = 32, bottom = 32 },
};
BACKDROP_DIALOG_32_32 = {
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
tile = true,
tileEdge = true,
tileSize = 32,
edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
};
BACKDROP_DARK_DIALOG_32_32 = {
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
tile = true,
tileEdge = true,
tileSize = 32,
edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
};
BACKDROP_DIALOG_EDGE_32 = {
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
tileEdge = true,
edgeSize = 32,
};
BACKDROP_GOLD_DIALOG_32_32 = {
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Gold-Border",
tile = true,
tileEdge = true,
tileSize = 32,
edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
};
BACKDROP_WATERMARK_DIALOG_0_16 = {
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-TestWatermark-Border",
tileEdge = true,
edgeSize = 16,
};
BACKDROP_SLIDER_8_8 = {
bgFile = "Interface\\Buttons\\UI-SliderBar-Background",
edgeFile = "Interface\\Buttons\\UI-SliderBar-Border",
tile = true,
tileEdge = true,
tileSize = 8,
edgeSize = 8,
insets = { left = 3, right = 3, top = 6, bottom = 6 },
};
BACKDROP_PARTY_32_32 = {
bgFile = "Interface\\CharacterFrame\\UI-Party-Background",
edgeFile = "Interface\\CharacterFrame\\UI-Party-Border",
tile = true,
tileEdge = true,
tileSize = 32,
edgeSize = 32,
insets = { left = 32, right = 32, top = 32, bottom = 32 },
};
BACKDROP_TOAST_12_12 = {
bgFile = "Interface\\FriendsFrame\\UI-Toast-Background",
edgeFile = "Interface\\FriendsFrame\\UI-Toast-Border",
tile = true,
tileEdge = true,
tileSize = 12,
edgeSize = 12,
insets = { left = 5, right = 5, top = 5, bottom = 5 },
};
BACKDROP_CALLOUT_GLOW_0_16 = {
edgeFile = "Interface\\TutorialFrame\\UI-TutorialFrame-CalloutGlow",
edgeSize = 16,
tileEdge = true,
};
BACKDROP_CALLOUT_GLOW_0_20 = {
edgeFile = "Interface\\TutorialFrame\\UI-TutorialFrame-CalloutGlow",
edgeSize = 20,
tileEdge = true,
};
BACKDROP_TEXT_PANEL_0_16 = {
edgeFile = "Interface\\Glues\\Common\\TextPanel-Border",
tileEdge = true,
edgeSize = 16,
};
BACKDROP_CHARACTER_CREATE_TOOLTIP_32_32 = {
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Glues\\Common\\TextPanel-Border",
tile = true,
tileEdge = true,
tileSize = 32,
edgeSize = 32,
insets = { left = 8, right = 4, top = 4, bottom = 8 },
};
BACKDROP_WRATH_CHARACTER_CREATE_TOOLTIP_32_32 = {
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
tile = true,
tileSize = 32,
insets = { left = 10, right = 0, top = 10, bottom = 6 },
};
BACKDROP_MISTS_CHARACTER_CREATE_TOOLTIP_32_32 = {
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
tile = true,
tileSize = 32,
insets = { left = 10, right = 10, top = 10, bottom = 6 },
};
BACKDROP_TUTORIAL_16_16 = {
bgFile = "Interface\\TutorialFrame\\TutorialFrameBackground",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true,
tileEdge = true,
tileSize = 16,
edgeSize = 16,
insets = { left = 3, right = 5, top = 3, bottom = 5 },
};
local NativeSetBackdrop = UIParent.SetBackdrop;
local NativeGetBackdrop = UIParent.GetBackdrop;
BackdropTemplateMixin = { };
function BackdropTemplateMixin:OnBackdropLoaded()
if self.backdropInfo then
-- check for invalid info
if not self.backdropInfo.edgeFile and not self.backdropInfo.bgFile then
self.backdropInfo = nil;
return;
end
self:ApplyBackdrop();
do
local r, g, b = 1, 1, 1;
if self.backdropColor then
r, g, b = self.backdropColor:GetRGB();
end
local a = self.backdropColorAlpha or 1;
self:SetBackdropColor(r, g, b, a);
end
do
local r, g, b = 1, 1, 1;
if self.backdropBorderColor then
r, g, b = self.backdropBorderColor:GetRGB();
end
local a = self.backdropBorderColorAlpha or 1;
self:SetBackdropBorderColor(r, g, b, a);
end
end
end
-- An unset or zero size draws at the engine's default, so report that.
function BackdropTemplateMixin:GetEdgeSize()
if self.backdropInfo.edgeSize and self.backdropInfo.edgeSize > 0 then
return self.backdropInfo.edgeSize;
else
return NativeGetBackdrop(self).edgeSize;
end
end
function BackdropTemplateMixin:HasBackdropInfo(backdropInfo)
return self.backdropInfo == backdropInfo;
end
function BackdropTemplateMixin:ClearBackdrop()
if self.backdropInfo then
NativeSetBackdrop(self, nil);
self.backdropInfo = nil;
end
end
-- A new engine backdrop starts with white colours, as retail's ApplyBackdrop
-- sets them.
function BackdropTemplateMixin:ApplyBackdrop()
NativeSetBackdrop(self, self.backdropInfo);
end
function BackdropTemplateMixin:SetBackdrop(backdropInfo)
if backdropInfo then
if self:HasBackdropInfo(backdropInfo) then
return;
end
if not backdropInfo.edgeFile and not backdropInfo.bgFile then
self:ClearBackdrop();
return;
end
self.backdropInfo = backdropInfo;
self:ApplyBackdrop();
else
self:ClearBackdrop();
end
end

Some files were not shown because too many files have changed in this diff Show More